Repository: postcss/postcss-import
Pull request #574 (2025-06-02 10:44:27)

Fix incorrect cascade layer order when some resources can not be inlined
Fixes the original problem described in https://github.com/postcss/postcss-import/issues/567#issue-2596115833

The root cause is that all nodes are added to either the `imports` or `bundle` array in https://github.com/postcss/postcss-import/blob/d43ca1506d65d9943a3c4bb885aff94dc809d69c/lib/parse-styles.js#L36-L37

While `@layer` statements (those without a rule body) can precede `@import`.
To fix this issue I've added a new statement type for layers and added the needed mechanics to apply conditions to these. To express conditional `@layer` statements before other `@import` statements, or `@layer` statements that end up in between other `@import` statements we use base64 encoded stylesheets:

`@import 'data:text/css;base64,QGxheWVyIGxheWVyLWJldGE=' print;` is equivalent to `@media print { @layer layer-beta }` but is valid before other `@import` statements

I am unsure if it fixes all other reported examples as I don't have reproductions for those. I suggest they test out the fix (if/when it lands) and file new issues if they still encounter problems.

I verified the fix by re-testing with https://github.com/romainmenke/css-import-tests
With the patch applied 4 more test cases pass, without regressions in other cases.


Patch:
diff --git a/lib/apply-conditions.js b/lib/apply-conditions.js
index 76a8475..2005c6b 100644
--- a/lib/apply-conditions.js
+++ b/lib/apply-conditions.js
@@ -3,12 +3,38 @@
 const base64EncodedConditionalImport = require("./base64-encoded-import")
 
 module.exports = function applyConditions(bundle, atRule) {
-  bundle.forEach(stmt => {
+  const firstImportStatementIndex = bundle.findIndex(
+    stmt => stmt.type === "import",
+  )
+  const lastImportStatementIndex = bundle.findLastIndex(
+    stmt => stmt.type === "import",
+  )
+
+  bundle.forEach((stmt, index) => {
+    if (stmt.type === "charset" || stmt.type === "warning") {
+      return
+    }
+
     if (
-      stmt.type === "charset" ||
-      stmt.type === "warning" ||
-      !stmt.conditions?.length
+      stmt.type === "layer" &&
+      ((index < lastImportStatementIndex && stmt.conditions?.length) ||
+        (index > firstImportStatementIndex && index < lastImportStatementIndex))
     ) {
+      stmt.type = "import"
+      stmt.node = stmt.node.clone({
+        name: "import",
+        params: base64EncodedConditionalImport(
+          `'data:text/css;base64,${Buffer.from(stmt.node.toString()).toString(
+            "base64",
+          )}'`,
+          stmt.conditions,
+        ),
+      })
+
+      return
+    }
+
+    if (!stmt.conditions?.length) {
       return
     }
 
@@ -20,8 +46,15 @@ module.exports = function applyConditions(bundle, atRule) {
       return
     }
 
-    const { nodes } = stmt
-    const { parent } = nodes[0]
+    let nodes
+    let parent
+    if (stmt.type === "layer") {
+      nodes = [stmt.node]
+      parent = stmt.node.parent
+    } else {
+      nodes = stmt.nodes
+      parent = nodes[0].parent
+    }
 
     const atRules = []
 
diff --git a/lib/apply-styles.js b/lib/apply-styles.js
index ca961b1..a92e7ff 100644
--- a/lib/apply-styles.js
+++ b/lib/apply-styles.js
@@ -5,7 +5,7 @@ module.exports = function applyStyles(bundle, styles) {
 
   // Strip additional statements.
   bundle.forEach(stmt => {
-    if (["charset", "import"].includes(stmt.type)) {
+    if (["charset", "import", "layer"].includes(stmt.type)) {
       stmt.node.parent = undefined
       styles.append(stmt.node)
     } else if (stmt.type === "nodes") {
diff --git a/lib/base64-encoded-import.js b/lib/base64-encoded-import.js
index a928c62..5e49779 100644
--- a/lib/base64-encoded-import.js
+++ b/lib/base64-encoded-import.js
@@ -8,6 +8,8 @@ const formatImportPrelude = require("./format-import-prelude")
 // To achieve this we create a list of base64 encoded imports, where each import contains a stylesheet with another import.
 // Each import can define a single group of conditions and a single cascade layer.
 module.exports = function base64EncodedConditionalImport(prelude, conditions) {
+  if (!conditions?.length) return prelude
+
   conditions.reverse()
   const first = conditions.pop()
   let params = `${prelude} ${formatImportPrelude(
diff --git a/lib/parse-statements.js b/lib/parse-statements.js
index 788193a..3df0bad 100644
--- a/lib/parse-statements.js
+++ b/lib/parse-statements.js
@@ -9,6 +9,7 @@ const { stringify } = valueParser
 module.exports = function parseStatements(result, styles, conditions, from) {
   const statements = []
   let nodes = []
+  let encounteredNonImportNodes = false
 
   styles.each(node => {
     let stmt
@@ -17,6 +18,14 @@ module.exports = function parseStatements(result, styles, conditions, from) {
         stmt = parseImport(result, node, conditions, from)
       else if (node.name === "charset")
         stmt = parseCharset(result, node, conditions, from)
+      else if (
+        node.name === "layer" &&
+        !encounteredNonImportNodes &&
+        !node.nodes
+      )
+        stmt = parseLayer(result, node, conditions, from)
+    } else if (node.type !== "comment") {
+      encounteredNonImportNodes = true
     }
 
     if (stmt) {
@@ -233,3 +242,12 @@ function parseImport(result, atRule, conditions, from) {
 
   return stmt
 }
+
+function parseLayer(result, atRule, conditions, from) {
+  return {
+    type: "layer",
+    node: atRule,
+    conditions: [...conditions],
+    from,
+  }
+}
diff --git a/lib/parse-styles.js b/lib/parse-styles.js
index 9dd43d7..766de9c 100644
--- a/lib/parse-styles.js
+++ b/lib/parse-styles.js
@@ -33,7 +33,7 @@ async function parseStyles(
   }
 
   let charset
-  const imports = []
+  const beforeBundle = []
   const bundle = []
 
   function handleCharset(stmt) {
@@ -56,19 +56,24 @@ async function parseStyles(
     else if (stmt.type === "import") {
       if (stmt.children) {
         stmt.children.forEach((child, index) => {
-          if (child.type === "import") imports.push(child)
+          if (child.type === "import") beforeBundle.push(child)
+          else if (child.type === "layer") beforeBundle.push(child)
           else if (child.type === "charset") handleCharset(child)
           else bundle.push(child)
           // For better output
           if (index === 0) child.parent = stmt
         })
-      } else imports.push(stmt)
+      } else beforeBundle.push(stmt)
+    } else if (stmt.type === "layer") {
+      beforeBundle.push(stmt)
     } else if (stmt.type === "nodes") {
       bundle.push(stmt)
     }
   })
 
-  return charset ? [charset, ...imports.concat(bundle)] : imports.concat(bundle)
+  return charset
+    ? [charset, ...beforeBundle.concat(bundle)]
+    : beforeBundle.concat(bundle)
 }
 
 async function resolveImportId(result, stmt, options, state, postcss) {
diff --git a/package.json b/package.json
index b4b2e9e..2203501 100644
--- a/package.json
+++ b/package.json
@@ -27,14 +27,14 @@
   },
   "devDependencies": {
     "ava": "^6.0.0",
-    "c8": "^9.0.0",
+    "c8": "^10.0.0",
     "eslint": "^8.27.0",
     "eslint-config-problems": "^8.0.0",
     "eslint-plugin-prettier": "^5.0.0",
     "postcss": "^8.0.0",
     "postcss-scss": "^4.0.0",
     "prettier": "~3.4.0",
-    "sugarss": "^4.0.0"
+    "sugarss": "^5.0.0"
   },
   "peerDependencies": {
     "postcss": "^8.0.0"


Test patch:
diff --git a/test/fixtures/imports/layer-followed-by-ignore.css b/test/fixtures/imports/layer-followed-by-ignore.css
new file mode 100644
index 0000000..13f9646
--- /dev/null
+++ b/test/fixtures/imports/layer-followed-by-ignore.css
@@ -0,0 +1,4 @@
+/* a comment */
+
+@layer layer-alpha;
+@import "http://css";
diff --git a/test/fixtures/imports/layer-only.css b/test/fixtures/imports/layer-only.css
new file mode 100644
index 0000000..9dc1acc
--- /dev/null
+++ b/test/fixtures/imports/layer-only.css
@@ -0,0 +1,1 @@
+@layer layer-beta;
diff --git a/test/fixtures/layer-followed-by-ignore-with-conditions.css b/test/fixtures/layer-followed-by-ignore-with-conditions.css
new file mode 100644
index 0000000..e5cf2e5
--- /dev/null
+++ b/test/fixtures/layer-followed-by-ignore-with-conditions.css
@@ -0,0 +1,2 @@
+@import "layer-only.css" print;
+@import "layer-followed-by-ignore.css" screen;
diff --git a/test/fixtures/layer-followed-by-ignore-with-conditions.expected.css b/test/fixtures/layer-followed-by-ignore-with-conditions.expected.css
new file mode 100644
index 0000000..3d1a219
--- /dev/null
+++ b/test/fixtures/layer-followed-by-ignore-with-conditions.expected.css
@@ -0,0 +1,6 @@
+@import 'data:text/css;base64,QGxheWVyIGxheWVyLWJldGE=' print;
+@import 'data:text/css;base64,QGxheWVyIGxheWVyLWFscGhh' screen;
+@import "http://css" screen;
+@media screen{
+/* a comment */
+}
diff --git a/test/fixtures/layer-followed-by-ignore-without-conditions.css b/test/fixtures/layer-followed-by-ignore-without-conditions.css
new file mode 100644
index 0000000..6b48f60
--- /dev/null
+++ b/test/fixtures/layer-followed-by-ignore-without-conditions.css
@@ -0,0 +1,3 @@
+@import "http://css-a";
+@import url("layer-only.css");
+@import "http://css-b";
diff --git a/test/fixtures/layer-followed-by-ignore-without-conditions.expected.css b/test/fixtures/layer-followed-by-ignore-without-conditions.expected.css
new file mode 100644
index 0000000..6e8818a
--- /dev/null
+++ b/test/fixtures/layer-followed-by-ignore-without-conditions.expected.css
@@ -0,0 +1,3 @@
+@import "http://css-a";
+@import 'data:text/css;base64,QGxheWVyIGxheWVyLWJldGE=';
+@import "http://css-b";
diff --git a/test/fixtures/layer-followed-by-ignore.css b/test/fixtures/layer-followed-by-ignore.css
new file mode 100644
index 0000000..1c4c793
--- /dev/null
+++ b/test/fixtures/layer-followed-by-ignore.css
@@ -0,0 +1,2 @@
+@layer layer-alpha;
+@import "http://css";
diff --git a/test/fixtures/layer-followed-by-ignore.expected.css b/test/fixtures/layer-followed-by-ignore.expected.css
new file mode 100644
index 0000000..1c4c793
--- /dev/null
+++ b/test/fixtures/layer-followed-by-ignore.expected.css
@@ -0,0 +1,2 @@
+@layer layer-alpha;
+@import "http://css";
diff --git a/test/fixtures/layer-statement-with-conditions.css b/test/fixtures/layer-statement-with-conditions.css
new file mode 100644
index 0000000..2256df4
--- /dev/null
+++ b/test/fixtures/layer-statement-with-conditions.css
@@ -0,0 +1,1 @@
+@import "layer-only.css" print;
diff --git a/test/fixtures/layer-statement-with-conditions.expected.css b/test/fixtures/layer-statement-with-conditions.expected.css
new file mode 100644
index 0000000..9f57df6
--- /dev/null
+++ b/test/fixtures/layer-statement-with-conditions.expected.css
@@ -0,0 +1,3 @@
+@media print{
+@layer layer-beta
+}
diff --git a/test/layer.js b/test/layer.js
index c80cc32..52236a6 100644
--- a/test/layer.js
+++ b/test/layer.js
@@ -33,3 +33,27 @@ test(
   checkFixture,
   "layer-duplicate-anonymous-imports-skip",
 )
+
+test(
+  "should correctly handle layer statements followed by ignored imports",
+  checkFixture,
+  "layer-followed-by-ignore",
+)
+
+test(
+  "should correctly handle layer statements followed by ignored imports in conditional imports",
+  checkFixture,
+  "layer-followed-by-ignore-with-conditions",
+)
+
+test(
+  "should correctly handle layer statements followed by ignored imports in unconditional imports",
+  checkFixture,
+  "layer-followed-by-ignore-without-conditions",
+)
+
+test(
+  "should correctly handle layer statements in conditional imports",
+  checkFixture,
+  "layer-statement-with-conditions",
+)