Skip to content
This repository was archived by the owner on Aug 28, 2026. It is now read-only.

Commit bd20834

Browse files
fix: strip BOM before processing with postcss (#1684)
postcss stripped a leading BOM until 8.5.24 and preserves it since, so a BOM added by an earlier loader (sass-loader with `charset: true`) survived into the module and landed mid-file once stylesheets were concatenated, silently dropping the rule after it. Strip it for both inputs postcss accepts: the string, and the `Root` a previous loader hands over as `meta.ast`, where the BOM lives on `source.input.hasBOM` rather than in the tree.
1 parent 488cdb1 commit bd20834

8 files changed

Lines changed: 154 additions & 1 deletion

File tree

‎src/index.js‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import {
2828
warningFactory,
2929
syntaxErrorFactory,
3030
supportTemplateLiteral,
31+
stripBom,
3132
} from "./utils";
3233

3334
export default async function loader(content, map, meta) {
@@ -174,7 +175,7 @@ export default async function loader(content, map, meta) {
174175
let result;
175176

176177
try {
177-
result = await postcss(plugins).process(content, {
178+
result = await postcss(plugins).process(stripBom(content), {
178179
hideNothingWarning: true,
179180
from: resourcePath,
180181
to: resourcePath,

‎src/utils.js‎

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1471,6 +1471,27 @@ function supportTemplateLiteral(loaderContext) {
14711471
return false;
14721472
}
14731473

1474+
const BOM_CODE_POINT = 0xfeff;
1475+
1476+
// postcss stripped a leading BOM until 8.5.24 and preserves it since, so strip
1477+
// it ourselves: concatenated stylesheets must not carry one in the middle.
1478+
// `content` is a postcss `Root` when a previous loader handed over its AST,
1479+
// and there the BOM lives on the input rather than in the tree.
1480+
function stripBom(content) {
1481+
if (typeof content === "string") {
1482+
return content.charCodeAt(0) === BOM_CODE_POINT
1483+
? content.slice(1)
1484+
: content;
1485+
}
1486+
1487+
if (content && content.source && content.source.input) {
1488+
// eslint-disable-next-line no-param-reassign
1489+
content.source.input.hasBOM = false;
1490+
}
1491+
1492+
return content;
1493+
}
1494+
14741495
export {
14751496
normalizeOptions,
14761497
shouldUseModulesPlugins,
@@ -1499,4 +1520,5 @@ export {
14991520
warningFactory,
15001521
syntaxErrorFactory,
15011522
supportTemplateLiteral,
1523+
stripBom,
15021524
};

‎test/__snapshots__/loader.test.js.snap‎

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,39 @@ exports[`loader should not generate console.warn when plugins disabled and hideN
6363

6464
exports[`loader should not generate console.warn when plugins disabled and hideNothingWarning is "true": warnings 1`] = `[]`;
6565

66+
exports[`loader should not keep a BOM in an ast reused from a previous loader: errors 1`] = `[]`;
67+
68+
exports[`loader should not keep a BOM in an ast reused from a previous loader: warnings 1`] = `[]`;
69+
70+
exports[`loader should not pass a BOM added by a previous loader to postcss: errors 1`] = `[]`;
71+
72+
exports[`loader should not pass a BOM added by a previous loader to postcss: module 1`] = `
73+
"// Imports
74+
import ___CSS_LOADER_API_NO_SOURCEMAP_IMPORT___ from "../../../../src/runtime/noSourceMaps.js";
75+
import ___CSS_LOADER_API_IMPORT___ from "../../../../src/runtime/api.js";
76+
var ___CSS_LOADER_EXPORT___ = ___CSS_LOADER_API_IMPORT___(___CSS_LOADER_API_NO_SOURCEMAP_IMPORT___);
77+
// Module
78+
___CSS_LOADER_EXPORT___.push([module.id, \`.first::after {
79+
content: "©";
80+
}
81+
\`, ""]);
82+
// Exports
83+
export default ___CSS_LOADER_EXPORT___;
84+
"
85+
`;
86+
87+
exports[`loader should not pass a BOM added by a previous loader to postcss: result 1`] = `
88+
".first::after {
89+
content: "©";
90+
}
91+
.second::after {
92+
content: "→";
93+
}
94+
"
95+
`;
96+
97+
exports[`loader should not pass a BOM added by a previous loader to postcss: warnings 1`] = `[]`;
98+
6699
exports[`loader should pass queries to other loader: errors 1`] = `[]`;
67100

68101
exports[`loader should pass queries to other loader: module 1`] = `
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
.first::after {
2+
content: "©";
3+
}
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
.second::after {
2+
content: "→";
3+
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import first from "./first.css";
2+
import second from "./second.css";
3+
4+
// Concatenation is where a preserved BOM corrupts: it lands mid-file.
5+
const css = first.toString() + second.toString();
6+
7+
__export__ = css;
8+
9+
export default css;
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
// Emulates sass-loader with `charset: true`, which prefixes a BOM when the
2+
// stylesheet is non-ASCII.
3+
module.exports = (content) => "\uFEFF" + content;

‎test/loader.test.js‎

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import path from "path";
22

3+
import postcss from "postcss";
34
import postcssPresetEnv from "postcss-preset-env";
45

56
import {
@@ -207,6 +208,84 @@ describe("loader", () => {
207208
expect(getErrors(stats)).toMatchSnapshot("errors");
208209
});
209210

211+
it("should not pass a BOM added by a previous loader to postcss", async () => {
212+
const BOM = "\uFEFF";
213+
const processSpy = jest.spyOn(Object.getPrototypeOf(postcss()), "process");
214+
const compiler = getCompiler(
215+
"./modules/issue-1678/source.js",
216+
{},
217+
{
218+
module: {
219+
rules: [
220+
{
221+
test: /\.css$/i,
222+
use: [
223+
{ loader: path.resolve(__dirname, "../src") },
224+
{ loader: "./modules/issue-1678/with-bom-loader.js" },
225+
],
226+
},
227+
],
228+
},
229+
},
230+
);
231+
232+
const stats = await compile(compiler);
233+
const executed = getExecutedCode("main.bundle.js", compiler, stats);
234+
235+
expect(processSpy).toHaveBeenCalledTimes(2);
236+
237+
for (const [css] of processSpy.mock.calls) {
238+
expect(css.startsWith(BOM)).toBe(false);
239+
}
240+
241+
// postcss >= 8.5.24 keeps the BOM, so without stripping it lands between
242+
// the two stylesheets and a browser drops the rule after it.
243+
expect(executed).not.toContain(BOM);
244+
expect(executed).toContain(".first::after");
245+
expect(executed).toContain(".second::after");
246+
247+
expect(
248+
getModuleSource("./modules/issue-1678/first.css", stats),
249+
).toMatchSnapshot("module");
250+
expect(executed).toMatchSnapshot("result");
251+
expect(getWarnings(stats)).toMatchSnapshot("warnings");
252+
expect(getErrors(stats)).toMatchSnapshot("errors");
253+
254+
processSpy.mockRestore();
255+
});
256+
257+
it("should not keep a BOM in an ast reused from a previous loader", async () => {
258+
const BOM = "\uFEFF";
259+
const compiler = getCompiler(
260+
"./modules/issue-1678/source.js",
261+
{},
262+
{
263+
module: {
264+
rules: [
265+
{
266+
test: /\.css$/i,
267+
use: [
268+
{ loader: path.resolve(__dirname, "../src") },
269+
{ loader: require.resolve("./helpers/ast-loader") },
270+
{ loader: "./modules/issue-1678/with-bom-loader.js" },
271+
],
272+
},
273+
],
274+
},
275+
},
276+
);
277+
278+
const stats = await compile(compiler);
279+
const executed = getExecutedCode("main.bundle.js", compiler, stats);
280+
281+
expect(executed).not.toContain(BOM);
282+
expect(executed).toContain(".first::after");
283+
expect(executed).toContain(".second::after");
284+
285+
expect(getWarnings(stats)).toMatchSnapshot("warnings");
286+
expect(getErrors(stats)).toMatchSnapshot("errors");
287+
});
288+
210289
it('should work with "sass-loader"', async () => {
211290
const compiler = getCompiler(
212291
"./scss/source.js",

0 commit comments

Comments
 (0)