From 136fa4bb39050c4095ebe0be1b6c922b4eac154d Mon Sep 17 00:00:00 2001 From: intellild Date: Fri, 11 Sep 2026 11:04:35 +0800 Subject: [PATCH 1/6] fix(loader): preserve string BOM across native boundaries --- .../src/plugins/js_loader/context.rs | 2 +- packages/rspack/src/loader-runner/index.ts | 4 +++- .../configCases/loader/utf8-hint-bom/index.js | 12 ++++++++++ .../configCases/loader/utf8-hint-bom/input.js | 1 + .../loader/utf8-hint-bom/normal-loader.js | 3 +++ .../loader/utf8-hint-bom/producer-loader.js | 4 ++++ .../loader/utf8-hint-bom/raw-loader.js | 5 ++++ .../loader/utf8-hint-bom/rspack.config.js | 24 +++++++++++++++++++ 8 files changed, 53 insertions(+), 2 deletions(-) create mode 100644 tests/rspack-test/configCases/loader/utf8-hint-bom/index.js create mode 100644 tests/rspack-test/configCases/loader/utf8-hint-bom/input.js create mode 100644 tests/rspack-test/configCases/loader/utf8-hint-bom/normal-loader.js create mode 100644 tests/rspack-test/configCases/loader/utf8-hint-bom/producer-loader.js create mode 100644 tests/rspack-test/configCases/loader/utf8-hint-bom/raw-loader.js create mode 100644 tests/rspack-test/configCases/loader/utf8-hint-bom/rspack.config.js diff --git a/crates/rspack_binding_api/src/plugins/js_loader/context.rs b/crates/rspack_binding_api/src/plugins/js_loader/context.rs index 1812ce718923..faf4a6198ac8 100644 --- a/crates/rspack_binding_api/src/plugins/js_loader/context.rs +++ b/crates/rspack_binding_api/src/plugins/js_loader/context.rs @@ -265,7 +265,7 @@ impl TryFrom<&mut LoaderContext> for JsLoaderContext { .collect(), ) }), - utf8_hint: None, + utf8_hint: cx.content().map(|content| !content.is_buffer()), }) } } diff --git a/packages/rspack/src/loader-runner/index.ts b/packages/rspack/src/loader-runner/index.ts index b06a17ee9236..477609e66e3f 100644 --- a/packages/rspack/src/loader-runner/index.ts +++ b/packages/rspack/src/loader-runner/index.ts @@ -1144,7 +1144,9 @@ export async function runLoaders( } case JsLoaderState.Normal: { let content: Parameters[0] | null | undefined = - context.content; + context.__internal__utf8Hint && context.content + ? context.content.toString('utf-8') + : context.content; const rawSourceMap = context.sourceMap; let sourceMap: string | object | undefined; let sourceMapParsed = false; diff --git a/tests/rspack-test/configCases/loader/utf8-hint-bom/index.js b/tests/rspack-test/configCases/loader/utf8-hint-bom/index.js new file mode 100644 index 000000000000..4052335d9395 --- /dev/null +++ b/tests/rspack-test/configCases/loader/utf8-hint-bom/index.js @@ -0,0 +1,12 @@ +it("should preserve a string BOM across loader boundaries", () => { + expect(require("./input.js?string-normal")).toBe("\ufeffhello"); +}); + +it("should strip a Buffer BOM when converting to a string", () => { + expect(require("./input.js?buffer-normal")).toBe("hello"); +}); + +it("should preserve BOM bytes for raw loaders", () => { + expect(require("./input.js?string-raw")).toBe("efbbbf68656c6c6f"); + expect(require("./input.js?buffer-raw")).toBe("efbbbf68656c6c6f"); +}); diff --git a/tests/rspack-test/configCases/loader/utf8-hint-bom/input.js b/tests/rspack-test/configCases/loader/utf8-hint-bom/input.js new file mode 100644 index 000000000000..c538b5cf27a5 --- /dev/null +++ b/tests/rspack-test/configCases/loader/utf8-hint-bom/input.js @@ -0,0 +1 @@ +// Replaced by producer-loader. diff --git a/tests/rspack-test/configCases/loader/utf8-hint-bom/normal-loader.js b/tests/rspack-test/configCases/loader/utf8-hint-bom/normal-loader.js new file mode 100644 index 000000000000..fe0dd3c0b08d --- /dev/null +++ b/tests/rspack-test/configCases/loader/utf8-hint-bom/normal-loader.js @@ -0,0 +1,3 @@ +module.exports = function (content) { + return `module.exports = ${JSON.stringify(content)};`; +}; diff --git a/tests/rspack-test/configCases/loader/utf8-hint-bom/producer-loader.js b/tests/rspack-test/configCases/loader/utf8-hint-bom/producer-loader.js new file mode 100644 index 000000000000..7cefead27f69 --- /dev/null +++ b/tests/rspack-test/configCases/loader/utf8-hint-bom/producer-loader.js @@ -0,0 +1,4 @@ +module.exports = function () { + const content = "\ufeffhello"; + return this.getOptions().kind === "string" ? content : Buffer.from(content); +}; diff --git a/tests/rspack-test/configCases/loader/utf8-hint-bom/raw-loader.js b/tests/rspack-test/configCases/loader/utf8-hint-bom/raw-loader.js new file mode 100644 index 000000000000..f110cf8001ba --- /dev/null +++ b/tests/rspack-test/configCases/loader/utf8-hint-bom/raw-loader.js @@ -0,0 +1,5 @@ +module.exports = function (content) { + if (!Buffer.isBuffer(content)) throw new Error("Expected a Buffer"); + return `module.exports = ${JSON.stringify(content.toString("hex"))};`; +}; +module.exports.raw = true; diff --git a/tests/rspack-test/configCases/loader/utf8-hint-bom/rspack.config.js b/tests/rspack-test/configCases/loader/utf8-hint-bom/rspack.config.js new file mode 100644 index 000000000000..d229e4b50eb5 --- /dev/null +++ b/tests/rspack-test/configCases/loader/utf8-hint-bom/rspack.config.js @@ -0,0 +1,24 @@ +module.exports = [false, true].flatMap((parallel) => + [false, true].map((mixed) => ({ + module: { + rules: ['string', 'buffer'].flatMap((kind) => + [false, true].map((raw) => ({ + resourceQuery: new RegExp(`^\\?${kind}-${raw ? 'raw' : 'normal'}$`), + use: [ + { + loader: require.resolve(raw ? './raw-loader' : './normal-loader'), + options: {}, + parallel: parallel ? { maxWorkers: 1 } : false, + }, + ...(mixed ? ['builtin:test-passthrough-loader'] : []), + { + loader: require.resolve('./producer-loader'), + options: { kind }, + parallel: parallel ? { maxWorkers: 1 } : false, + }, + ], + })), + ), + }, + })), +); From 47b99e2dc91eaa9d3889f33ced89de5586f10f43 Mon Sep 17 00:00:00 2001 From: intellild Date: Fri, 11 Sep 2026 14:00:44 +0800 Subject: [PATCH 2/6] fix(loader): preserve UTF-8 hint for pitch results --- packages/rspack/src/loader-runner/index.ts | 1 + .../loader/utf8-hint-bom/pitch-loader.js | 5 ++ .../loader/utf8-hint-bom/rspack.config.js | 52 +++++++++++-------- 3 files changed, 35 insertions(+), 23 deletions(-) create mode 100644 tests/rspack-test/configCases/loader/utf8-hint-bom/pitch-loader.js diff --git a/packages/rspack/src/loader-runner/index.ts b/packages/rspack/src/loader-runner/index.ts index 477609e66e3f..d80b12169818 100644 --- a/packages/rspack/src/loader-runner/index.ts +++ b/packages/rspack/src/loader-runner/index.ts @@ -1136,6 +1136,7 @@ export async function runLoaders( context.content = isNil(content) ? null : toBuffer(content); context.sourceMap = serializeObject(sourceMap); context.additionalData = additionalData || undefined; + context.__internal__utf8Hint = typeof content === 'string'; break; } } diff --git a/tests/rspack-test/configCases/loader/utf8-hint-bom/pitch-loader.js b/tests/rspack-test/configCases/loader/utf8-hint-bom/pitch-loader.js new file mode 100644 index 000000000000..61b30bb2e599 --- /dev/null +++ b/tests/rspack-test/configCases/loader/utf8-hint-bom/pitch-loader.js @@ -0,0 +1,5 @@ +module.exports = function () { + throw new Error("A pitch result should skip the producer normal function"); +}; + +module.exports.pitch = require("./producer-loader"); diff --git a/tests/rspack-test/configCases/loader/utf8-hint-bom/rspack.config.js b/tests/rspack-test/configCases/loader/utf8-hint-bom/rspack.config.js index d229e4b50eb5..349f0759ec56 100644 --- a/tests/rspack-test/configCases/loader/utf8-hint-bom/rspack.config.js +++ b/tests/rspack-test/configCases/loader/utf8-hint-bom/rspack.config.js @@ -1,24 +1,30 @@ -module.exports = [false, true].flatMap((parallel) => - [false, true].map((mixed) => ({ - module: { - rules: ['string', 'buffer'].flatMap((kind) => - [false, true].map((raw) => ({ - resourceQuery: new RegExp(`^\\?${kind}-${raw ? 'raw' : 'normal'}$`), - use: [ - { - loader: require.resolve(raw ? './raw-loader' : './normal-loader'), - options: {}, - parallel: parallel ? { maxWorkers: 1 } : false, - }, - ...(mixed ? ['builtin:test-passthrough-loader'] : []), - { - loader: require.resolve('./producer-loader'), - options: { kind }, - parallel: parallel ? { maxWorkers: 1 } : false, - }, - ], - })), - ), - }, - })), +module.exports = [false, true].flatMap((pitch) => + [false, true].flatMap((parallel) => + [false, true].map((mixed) => ({ + module: { + rules: ['string', 'buffer'].flatMap((kind) => + [false, true].map((raw) => ({ + resourceQuery: new RegExp(`^\\?${kind}-${raw ? 'raw' : 'normal'}$`), + use: [ + { + loader: require.resolve( + raw ? './raw-loader' : './normal-loader', + ), + options: {}, + parallel: parallel ? { maxWorkers: 1 } : false, + }, + ...(mixed ? ['builtin:test-passthrough-loader'] : []), + { + loader: require.resolve( + pitch ? './pitch-loader' : './producer-loader', + ), + options: { kind }, + parallel: parallel ? { maxWorkers: 1 } : false, + }, + ], + })), + ), + }, + })), + ), ); From 47688a8f211b19fdf92163a60fca7037ed268f70 Mon Sep 17 00:00:00 2001 From: intellild Date: Fri, 11 Sep 2026 15:49:32 +0800 Subject: [PATCH 3/6] fix(loader): distinguish content types in cache keys --- .../src/plugins/js_loader/cache.rs | 6 +-- crates/rspack_core/src/loader/loader_cache.rs | 2 + .../loader-cache-content-type/0/index.js | 15 +++++++ .../loader-cache-content-type/0/input.txt | 1 + .../loader-cache-content-type/1/input.txt | 1 + .../loader-cache-content-type/2/input.txt | 1 + .../loader-cache-content-type/3/input.txt | 1 + .../consumer-loader.js | 8 ++++ .../producer-loader.js | 6 +++ .../rspack.config.js | 39 +++++++++++++++++++ .../loader-cache-content-type/test.filter.js | 1 + 11 files changed, 78 insertions(+), 3 deletions(-) create mode 100644 tests/rspack-test/watchCases/loaders/loader-cache-content-type/0/index.js create mode 100644 tests/rspack-test/watchCases/loaders/loader-cache-content-type/0/input.txt create mode 100644 tests/rspack-test/watchCases/loaders/loader-cache-content-type/1/input.txt create mode 100644 tests/rspack-test/watchCases/loaders/loader-cache-content-type/2/input.txt create mode 100644 tests/rspack-test/watchCases/loaders/loader-cache-content-type/3/input.txt create mode 100644 tests/rspack-test/watchCases/loaders/loader-cache-content-type/consumer-loader.js create mode 100644 tests/rspack-test/watchCases/loaders/loader-cache-content-type/producer-loader.js create mode 100644 tests/rspack-test/watchCases/loaders/loader-cache-content-type/rspack.config.js create mode 100644 tests/rspack-test/watchCases/loaders/loader-cache-content-type/test.filter.js diff --git a/crates/rspack_binding_api/src/plugins/js_loader/cache.rs b/crates/rspack_binding_api/src/plugins/js_loader/cache.rs index 57f06d887b12..b9cbab2c6122 100644 --- a/crates/rspack_binding_api/src/plugins/js_loader/cache.rs +++ b/crates/rspack_binding_api/src/plugins/js_loader/cache.rs @@ -143,12 +143,12 @@ impl JsLoaderCache { ) -> napi::Result> { let loader = self.loader(loader_index)?; let content = match content { - Either::A(content) => content.into_bytes(), - Either::B(content) => content.to_vec(), + Either::A(content) => Content::String(content), + Either::B(content) => Content::Buffer(content.to_vec()), }; let existing: LoaderDependencies = existing.into(); let etag = loader_cache_etag( - &Content::Buffer(content), + &content, &existing, &loader.options_cache_key, &loader.loader_version, diff --git a/crates/rspack_core/src/loader/loader_cache.rs b/crates/rspack_core/src/loader/loader_cache.rs index 1c1e05d22beb..8d3f88dc5d01 100644 --- a/crates/rspack_core/src/loader/loader_cache.rs +++ b/crates/rspack_core/src/loader/loader_cache.rs @@ -43,7 +43,9 @@ pub fn loader_cache_etag( // Context and missing dependencies intentionally invalidate the minimal cache: inherited values // disable lookup, and entries that add either kind are skipped at store time. This trade-off lets // the etag omit both kinds entirely. + // Equal bytes are not equivalent inputs: non-raw JS loaders strip a BOM only from buffers. rspack_hash::rspack_hash_object!(&mut hasher, { + "content_is_string" => !content.is_buffer(), "content" => content, "file_dependencies" => sorted_dependency_paths(&existing.file), "build_dependencies" => sorted_dependency_paths(&existing.build), diff --git a/tests/rspack-test/watchCases/loaders/loader-cache-content-type/0/index.js b/tests/rspack-test/watchCases/loaders/loader-cache-content-type/0/index.js new file mode 100644 index 000000000000..3e3b5aadccf6 --- /dev/null +++ b/tests/rspack-test/watchCases/loaders/loader-cache-content-type/0/index.js @@ -0,0 +1,15 @@ +const stringFirst = require("./input.txt?string-first"); +const bufferFirst = require("./input.txt?buffer-first"); + +it("should distinguish equal bytes with different content types in the loader cache", () => { + const step = +WATCH_STEP; + const runs = LOADER_CACHE_ENABLED ? (step < 2 ? 1 : 2) : step + 1; + expect(stringFirst).toEqual({ + content: step < 2 ? "\uFEFFhello" : "hello", + runs, + }); + expect(bufferFirst).toEqual({ + content: step < 2 ? "hello" : "\uFEFFhello", + runs, + }); +}); diff --git a/tests/rspack-test/watchCases/loaders/loader-cache-content-type/0/input.txt b/tests/rspack-test/watchCases/loaders/loader-cache-content-type/0/input.txt new file mode 100644 index 000000000000..573541ac9702 --- /dev/null +++ b/tests/rspack-test/watchCases/loaders/loader-cache-content-type/0/input.txt @@ -0,0 +1 @@ +0 diff --git a/tests/rspack-test/watchCases/loaders/loader-cache-content-type/1/input.txt b/tests/rspack-test/watchCases/loaders/loader-cache-content-type/1/input.txt new file mode 100644 index 000000000000..d00491fd7e5b --- /dev/null +++ b/tests/rspack-test/watchCases/loaders/loader-cache-content-type/1/input.txt @@ -0,0 +1 @@ +1 diff --git a/tests/rspack-test/watchCases/loaders/loader-cache-content-type/2/input.txt b/tests/rspack-test/watchCases/loaders/loader-cache-content-type/2/input.txt new file mode 100644 index 000000000000..0cfbf08886fc --- /dev/null +++ b/tests/rspack-test/watchCases/loaders/loader-cache-content-type/2/input.txt @@ -0,0 +1 @@ +2 diff --git a/tests/rspack-test/watchCases/loaders/loader-cache-content-type/3/input.txt b/tests/rspack-test/watchCases/loaders/loader-cache-content-type/3/input.txt new file mode 100644 index 000000000000..00750edc07d6 --- /dev/null +++ b/tests/rspack-test/watchCases/loaders/loader-cache-content-type/3/input.txt @@ -0,0 +1 @@ +3 diff --git a/tests/rspack-test/watchCases/loaders/loader-cache-content-type/consumer-loader.js b/tests/rspack-test/watchCases/loaders/loader-cache-content-type/consumer-loader.js new file mode 100644 index 000000000000..6fdcc6da2b65 --- /dev/null +++ b/tests/rspack-test/watchCases/loaders/loader-cache-content-type/consumer-loader.js @@ -0,0 +1,8 @@ +const runs = new Map(); + +module.exports = function (source) { + const key = `${this.getOptions().name}:${this.resource}`; + const count = (runs.get(key) || 0) + 1; + runs.set(key, count); + return `module.exports = ${JSON.stringify({ content: source, runs: count })};`; +}; diff --git a/tests/rspack-test/watchCases/loaders/loader-cache-content-type/producer-loader.js b/tests/rspack-test/watchCases/loaders/loader-cache-content-type/producer-loader.js new file mode 100644 index 000000000000..99d02d807747 --- /dev/null +++ b/tests/rspack-test/watchCases/loaders/loader-cache-content-type/producer-loader.js @@ -0,0 +1,6 @@ +module.exports = function (source) { + const firstType = this.resourceQuery === "?string-first"; + const isString = Number(source.trim()) < 2 ? firstType : !firstType; + const content = "\uFEFFhello"; + return isString ? content : Buffer.from(content); +}; diff --git a/tests/rspack-test/watchCases/loaders/loader-cache-content-type/rspack.config.js b/tests/rspack-test/watchCases/loaders/loader-cache-content-type/rspack.config.js new file mode 100644 index 000000000000..ad255de15434 --- /dev/null +++ b/tests/rspack-test/watchCases/loaders/loader-cache-content-type/rspack.config.js @@ -0,0 +1,39 @@ +const { rspack } = require('@rspack/core'); + +module.exports = [false, true].flatMap((cache) => + [false, true].flatMap((parallel) => + [false, true].map((mixed) => ({ + mode: 'development', + incremental: false, + cache: cache ? { type: 'memory' } : false, + experiments: { + newCache: { + codeGeneration: false, + loader: cache, + minimize: false, + }, + }, + module: { + rules: [ + { + test: /input\.txt$/, + type: 'javascript/auto', + use: [ + { + loader: require.resolve('./consumer-loader'), + options: { name: `${cache}-${parallel}-${mixed}` }, + cache, + parallel: parallel ? { maxWorkers: 1 } : false, + }, + ...(mixed + ? [{ loader: 'builtin:test-passthrough-loader', cache }] + : []), + { loader: require.resolve('./producer-loader') }, + ], + }, + ], + }, + plugins: [new rspack.DefinePlugin({ LOADER_CACHE_ENABLED: cache })], + })), + ), +); diff --git a/tests/rspack-test/watchCases/loaders/loader-cache-content-type/test.filter.js b/tests/rspack-test/watchCases/loaders/loader-cache-content-type/test.filter.js new file mode 100644 index 000000000000..40b04915cd1f --- /dev/null +++ b/tests/rspack-test/watchCases/loaders/loader-cache-content-type/test.filter.js @@ -0,0 +1 @@ +module.exports = () => !process.env.WASM; From 4d7a08a067f0926d1f97359c2007c6dfe0a2bf32 Mon Sep 17 00:00:00 2001 From: AsyncIter Date: Fri, 11 Sep 2026 17:36:17 +0800 Subject: [PATCH 4/6] refactor(loader): preserve content types across native boundaries --- crates/node_binding/napi-binding.d.ts | 7 +------ .../src/plugins/js_loader/context.rs | 15 +++++-------- .../src/plugins/js_loader/scheduler.rs | 21 ++++--------------- packages/rspack/src/loader-runner/index.ts | 18 +++++++++------- 4 files changed, 21 insertions(+), 40 deletions(-) diff --git a/crates/node_binding/napi-binding.d.ts b/crates/node_binding/napi-binding.d.ts index 535d8b3ac281..2285e7815e3a 100644 --- a/crates/node_binding/napi-binding.d.ts +++ b/crates/node_binding/napi-binding.d.ts @@ -978,7 +978,7 @@ export interface JsLoaderContext { _module: Module hot: Readonly /** Content maybe empty in pitching stage */ - content: null | Buffer + content: string | Buffer | null additionalData?: any __internal__parseMeta: Record sourceMap?: Buffer @@ -989,11 +989,6 @@ export interface JsLoaderContext { loaderState: Readonly __internal__error?: RspackError __internal__loaderCache?: JsLoaderCache | undefined - /** - * UTF-8 hint for `content` - * - Some(true): `content` is a `UTF-8` encoded sequence - */ - __internal__utf8Hint?: boolean } export interface JsLoaderDependencies { diff --git a/crates/rspack_binding_api/src/plugins/js_loader/context.rs b/crates/rspack_binding_api/src/plugins/js_loader/context.rs index faf4a6198ac8..ccc2a13dfc50 100644 --- a/crates/rspack_binding_api/src/plugins/js_loader/context.rs +++ b/crates/rspack_binding_api/src/plugins/js_loader/context.rs @@ -3,7 +3,7 @@ use std::{ptr::NonNull, sync::Arc}; use napi::bindgen_prelude::*; use napi_derive::napi; use rspack_collections::Identifiable; -use rspack_core::{LoaderContext, LoaderDependencies, Module, RunnerContext}; +use rspack_core::{Content, LoaderContext, LoaderDependencies, Module, RunnerContext}; use rspack_error::ToStringResultToRspackResultExt; use rspack_loader_runner::State as LoaderState; use rspack_napi::threadsafe_js_value_ref::ThreadsafeJsValueRef; @@ -180,7 +180,7 @@ pub struct JsLoaderContext { pub hot: bool, /// Content maybe empty in pitching stage - pub content: Either, + pub content: Either3, #[napi(ts_type = "any")] pub additional_data: Option>>, #[napi(js_name = "__internal__parseMeta")] @@ -200,11 +200,6 @@ pub struct JsLoaderContext { ts_type = "JsLoaderCache | undefined" )] pub loader_cache: Option, - - /// UTF-8 hint for `content` - /// - Some(true): `content` is a `UTF-8` encoded sequence - #[napi(js_name = "__internal__utf8Hint")] - pub utf8_hint: Option, } impl TryFrom<&mut LoaderContext> for JsLoaderContext { @@ -229,8 +224,9 @@ impl TryFrom<&mut LoaderContext> for JsLoaderContext { ), hot: cx.hot, content: match cx.content() { - Some(c) => Either::B(c.to_owned().into_bytes().into()), - None => Either::A(Null), + Some(Content::String(content)) => Either3::A(content.clone()), + Some(Content::Buffer(content)) => Either3::B(content.clone().into()), + None => Either3::C(Null), }, // Since js side only set parse meta, and can't read it, so we can use Default here to only bring the // set values from js side to rust side. @@ -265,7 +261,6 @@ impl TryFrom<&mut LoaderContext> for JsLoaderContext { .collect(), ) }), - utf8_hint: cx.content().map(|content| !content.is_buffer()), }) } } diff --git a/crates/rspack_binding_api/src/plugins/js_loader/scheduler.rs b/crates/rspack_binding_api/src/plugins/js_loader/scheduler.rs index aabfbc3e1c4e..b587a55fc2e5 100644 --- a/crates/rspack_binding_api/src/plugins/js_loader/scheduler.rs +++ b/crates/rspack_binding_api/src/plugins/js_loader/scheduler.rs @@ -1,4 +1,4 @@ -use napi::{Either, bindgen_prelude::JsValuesTupleIntoVec}; +use napi::bindgen_prelude::{Either3, JsValuesTupleIntoVec}; use rspack_core::{ AdditionalData, BUILTIN_LOADER_PREFIX, LoaderContext, NormalModuleLoaderShouldYield, NormalModuleLoaderStartYielding, RunnerContext, @@ -98,22 +98,9 @@ pub(crate) fn merge_loader_context( } let content = match from.content { - Either::A(_) => None, - Either::B(c) => { - // perf: Ignore UTF-8 check when JavaScript passed in an UTF-8 encoded value - let content = if let Some(utf8_hint) = from.utf8_hint - && utf8_hint - { - rspack_core::Content::from( - // SAFETY: UTF-8 passed from JavaScript loader runner should ensure it does not pass non-UTF-8 encoded sequence when `utf_hint` is set to `true`. This invariant should be followed on the JavaScript side. - unsafe { String::from_utf8_unchecked(c.into()) }, - ) - } else { - rspack_core::Content::from(Into::>::into(c)) - }; - - Some(content) - } + Either3::A(content) => Some(rspack_core::Content::String(content)), + Either3::B(content) => Some(rspack_core::Content::Buffer(content.into())), + Either3::C(_) => None, }; let source_map = from .source_map diff --git a/packages/rspack/src/loader-runner/index.ts b/packages/rspack/src/loader-runner/index.ts index d80b12169818..66fe66e803b8 100644 --- a/packages/rspack/src/loader-runner/index.ts +++ b/packages/rspack/src/loader-runner/index.ts @@ -1133,10 +1133,13 @@ export async function runLoaders( if (hasArg) { const [content, sourceMap, additionalData] = args; - context.content = isNil(content) ? null : toBuffer(content); + context.content = isNil(content) + ? null + : typeof content === 'string' + ? content + : toBuffer(content); context.sourceMap = serializeObject(sourceMap); context.additionalData = additionalData || undefined; - context.__internal__utf8Hint = typeof content === 'string'; break; } } @@ -1145,9 +1148,7 @@ export async function runLoaders( } case JsLoaderState.Normal: { let content: Parameters[0] | null | undefined = - context.__internal__utf8Hint && context.content - ? context.content.toString('utf-8') - : context.content; + context.content; const rawSourceMap = context.sourceMap; let sourceMap: string | object | undefined; let sourceMapParsed = false; @@ -1219,12 +1220,15 @@ export async function runLoaders( } } - context.content = isNil(content) ? null : toBuffer(content); + context.content = isNil(content) + ? null + : typeof content === 'string' + ? content + : toBuffer(content); context.sourceMap = sourceMapParsed ? JsSourceMap.__to_binding(sourceMap) : rawSourceMap; context.additionalData = additionalData || undefined; - context.__internal__utf8Hint = typeof content === 'string'; break; } From 5b1431c62a06f0a76425f54a76e0a490e2df4562 Mon Sep 17 00:00:00 2001 From: AsyncIter Date: Fri, 11 Sep 2026 17:51:59 +0800 Subject: [PATCH 5/6] fix(loader): preserve content types during source map extraction --- .../rspack_core/src/loader/rspack_loader.rs | 14 ++-- .../configCases/loader/utf8-hint-bom/index.js | 18 +++++ .../utf8-hint-bom/resource-with-map.txt | 2 + .../loader/utf8-hint-bom/resource.map | 1 + .../loader/utf8-hint-bom/resource.txt | 1 + .../loader/utf8-hint-bom/rspack.config.js | 67 +++++++++++++------ 6 files changed, 75 insertions(+), 28 deletions(-) create mode 100644 tests/rspack-test/configCases/loader/utf8-hint-bom/resource-with-map.txt create mode 100644 tests/rspack-test/configCases/loader/utf8-hint-bom/resource.map create mode 100644 tests/rspack-test/configCases/loader/utf8-hint-bom/resource.txt diff --git a/crates/rspack_core/src/loader/rspack_loader.rs b/crates/rspack_core/src/loader/rspack_loader.rs index cb2fd5eeda11..6bde50f6af7e 100644 --- a/crates/rspack_core/src/loader/rspack_loader.rs +++ b/crates/rspack_core/src/loader/rspack_loader.rs @@ -68,12 +68,14 @@ impl LoaderRunnerPlugin for RspackLoaderRunnerPlugin { .map(|deps| deps.into_iter().map(Into::into).collect()) .unwrap_or_default(); - // Return the content with source map extracted and file dependencies - return Ok(Some(( - Content::String(extract_result.source), - extract_result.source_map, - file_deps, - ))); + // Preserve the input type: non-raw JS loaders strip a BOM only from buffers. + let content = if content.is_buffer() { + Content::Buffer(extract_result.source.into_bytes()) + } else { + Content::String(extract_result.source) + }; + + return Ok(Some((content, extract_result.source_map, file_deps))); } Err(e) => { // If extraction fails, return original content with empty dependencies diff --git a/tests/rspack-test/configCases/loader/utf8-hint-bom/index.js b/tests/rspack-test/configCases/loader/utf8-hint-bom/index.js index 4052335d9395..e0ceb594dd83 100644 --- a/tests/rspack-test/configCases/loader/utf8-hint-bom/index.js +++ b/tests/rspack-test/configCases/loader/utf8-hint-bom/index.js @@ -10,3 +10,21 @@ it("should preserve BOM bytes for raw loaders", () => { expect(require("./input.js?string-raw")).toBe("efbbbf68656c6c6f"); expect(require("./input.js?buffer-raw")).toBe("efbbbf68656c6c6f"); }); + +it("should strip a resource BOM for normal loaders without a source map", () => { + expect(require("./resource.txt?resource-plain-normal")).toBe("hello\n"); + expect(require("./resource.txt?resource-extract-normal")).toBe("hello\n"); +}); + +it("should preserve resource BOM bytes for raw loaders without a source map", () => { + expect(require("./resource.txt?resource-plain-raw")).toBe("efbbbf68656c6c6f0a"); + expect(require("./resource.txt?resource-extract-raw")).toBe("efbbbf68656c6c6f0a"); +}); + +it("should strip a resource BOM for normal loaders after extracting a source map", () => { + expect(require("./resource-with-map.txt?resource-extract-normal")).toBe("hello\n"); +}); + +it("should preserve resource BOM bytes for raw loaders after extracting a source map", () => { + expect(require("./resource-with-map.txt?resource-extract-raw")).toBe("efbbbf68656c6c6f0a"); +}); diff --git a/tests/rspack-test/configCases/loader/utf8-hint-bom/resource-with-map.txt b/tests/rspack-test/configCases/loader/utf8-hint-bom/resource-with-map.txt new file mode 100644 index 000000000000..f8b0d82fcd66 --- /dev/null +++ b/tests/rspack-test/configCases/loader/utf8-hint-bom/resource-with-map.txt @@ -0,0 +1,2 @@ +hello +//# sourceMappingURL=resource.map diff --git a/tests/rspack-test/configCases/loader/utf8-hint-bom/resource.map b/tests/rspack-test/configCases/loader/utf8-hint-bom/resource.map new file mode 100644 index 000000000000..2adb393ce458 --- /dev/null +++ b/tests/rspack-test/configCases/loader/utf8-hint-bom/resource.map @@ -0,0 +1 @@ +{"version":3,"sources":[],"names":[],"mappings":""} diff --git a/tests/rspack-test/configCases/loader/utf8-hint-bom/resource.txt b/tests/rspack-test/configCases/loader/utf8-hint-bom/resource.txt new file mode 100644 index 000000000000..c2d46024e14d --- /dev/null +++ b/tests/rspack-test/configCases/loader/utf8-hint-bom/resource.txt @@ -0,0 +1 @@ +hello diff --git a/tests/rspack-test/configCases/loader/utf8-hint-bom/rspack.config.js b/tests/rspack-test/configCases/loader/utf8-hint-bom/rspack.config.js index 349f0759ec56..6dd0f8752d98 100644 --- a/tests/rspack-test/configCases/loader/utf8-hint-bom/rspack.config.js +++ b/tests/rspack-test/configCases/loader/utf8-hint-bom/rspack.config.js @@ -2,28 +2,51 @@ module.exports = [false, true].flatMap((pitch) => [false, true].flatMap((parallel) => [false, true].map((mixed) => ({ module: { - rules: ['string', 'buffer'].flatMap((kind) => - [false, true].map((raw) => ({ - resourceQuery: new RegExp(`^\\?${kind}-${raw ? 'raw' : 'normal'}$`), - use: [ - { - loader: require.resolve( - raw ? './raw-loader' : './normal-loader', - ), - options: {}, - parallel: parallel ? { maxWorkers: 1 } : false, - }, - ...(mixed ? ['builtin:test-passthrough-loader'] : []), - { - loader: require.resolve( - pitch ? './pitch-loader' : './producer-loader', - ), - options: { kind }, - parallel: parallel ? { maxWorkers: 1 } : false, - }, - ], - })), - ), + rules: [ + ...['string', 'buffer'].flatMap((kind) => + [false, true].map((raw) => ({ + resourceQuery: new RegExp( + `^\\?${kind}-${raw ? 'raw' : 'normal'}$`, + ), + use: [ + { + loader: require.resolve( + raw ? './raw-loader' : './normal-loader', + ), + options: {}, + parallel: parallel ? { maxWorkers: 1 } : false, + }, + ...(mixed ? ['builtin:test-passthrough-loader'] : []), + { + loader: require.resolve( + pitch ? './pitch-loader' : './producer-loader', + ), + options: { kind }, + parallel: parallel ? { maxWorkers: 1 } : false, + }, + ], + })), + ), + ...[false, true].flatMap((extractSourceMap) => + [false, true].map((raw) => ({ + resourceQuery: new RegExp( + `^\\?resource-${extractSourceMap ? 'extract' : 'plain'}-${raw ? 'raw' : 'normal'}$`, + ), + type: 'javascript/auto', + extractSourceMap, + use: [ + { + loader: require.resolve( + raw ? './raw-loader' : './normal-loader', + ), + options: {}, + parallel: parallel ? { maxWorkers: 1 } : false, + }, + ...(mixed ? ['builtin:test-passthrough-loader'] : []), + ], + })), + ), + ], }, })), ), From 9252ebe04459befad1f242ddc3a2cbfc2a58a44f Mon Sep 17 00:00:00 2001 From: AsyncIter Date: Fri, 11 Sep 2026 19:02:40 +0800 Subject: [PATCH 6/6] test(loader): isolate loader cache matrix outputs --- .../loaders/loader-cache-content-type/rspack.config.js | 3 +++ .../loaders/loader-cache-content-type/test.config.js | 5 +++++ 2 files changed, 8 insertions(+) create mode 100644 tests/rspack-test/watchCases/loaders/loader-cache-content-type/test.config.js diff --git a/tests/rspack-test/watchCases/loaders/loader-cache-content-type/rspack.config.js b/tests/rspack-test/watchCases/loaders/loader-cache-content-type/rspack.config.js index ad255de15434..4f97bc652497 100644 --- a/tests/rspack-test/watchCases/loaders/loader-cache-content-type/rspack.config.js +++ b/tests/rspack-test/watchCases/loaders/loader-cache-content-type/rspack.config.js @@ -4,6 +4,9 @@ module.exports = [false, true].flatMap((cache) => [false, true].flatMap((parallel) => [false, true].map((mixed) => ({ mode: 'development', + output: { + filename: `bundle-${cache}-${parallel}-${mixed}.js`, + }, incremental: false, cache: cache ? { type: 'memory' } : false, experiments: { diff --git a/tests/rspack-test/watchCases/loaders/loader-cache-content-type/test.config.js b/tests/rspack-test/watchCases/loaders/loader-cache-content-type/test.config.js new file mode 100644 index 000000000000..fbe50aac5799 --- /dev/null +++ b/tests/rspack-test/watchCases/loaders/loader-cache-content-type/test.config.js @@ -0,0 +1,5 @@ +module.exports = { + findBundle(_index, options) { + return options.output.filename; + }, +};