From 8fe11c77548a7e1084430d021b948bf11d301528 Mon Sep 17 00:00:00 2001
From: robobun <117481402+robobun@users.noreply.github.com>
Date: Tue, 7 Jul 2026 16:25:18 +0000
Subject: [PATCH 1/3] css: preserve url() query/fragment suffix when rewriting
asset references
A CSS url() with a fragment like url(sprites.svg#icon) lost the #icon
part when the referenced asset was rewritten to a hashed path or inlined
as a data: URL. The resolver strips the ?query/#fragment suffix to find
the file on disk, but nothing re-appended it to the rewritten reference,
so SVG sprite / mask / clip-path / paint-server references silently
pointed at the whole document instead of the fragment.
The original specifier (with suffix) is already preserved on the import
record as original_path. When the CSS printer emits a rewritten url()
reference (data: URL or file-loader unique key), it now re-appends any
?/# suffix found in original_path. External references are unchanged
since they keep their original text.
---
src/css/printer.rs | 25 +++++++++-
test/bundler/esbuild/css.test.ts | 79 ++++++++++++++++++++++++++++++++
2 files changed, 102 insertions(+), 2 deletions(-)
diff --git a/src/css/printer.rs b/src/css/printer.rs
index 42bc1815367a..59e0550c7b30 100644
--- a/src/css/printer.rs
+++ b/src/css/printer.rs
@@ -412,16 +412,37 @@ impl<'a> Printer<'a> {
};
let record = &import_info.import_records[import_record_idx as usize];
if record.source_index.is_valid() {
+ // A `url()`'s `?query`/`#fragment` (e.g. `url(sprites.svg#icon)`) is
+ // stripped by the resolver to find the file; re-append it to the
+ // rewritten reference so the fragment still addresses the element.
+ let suffix: &[u8] = if record.kind == bun_ast::ImportKind::Url {
+ match bun_core::strings::index_of_any(record.original_path, b"?#") {
+ Some(i) => &record.original_path[i..],
+ None => b"",
+ }
+ } else {
+ b""
+ };
+ let arena = self.arena;
+ let with_suffix = |url: &'a [u8]| -> &'a [u8] {
+ if suffix.is_empty() {
+ return url;
+ }
+ let buf = arena.alloc_slice_fill_copy(url.len() + suffix.len(), 0u8);
+ buf[..url.len()].copy_from_slice(url);
+ buf[url.len()..].copy_from_slice(suffix);
+ buf
+ };
// It has an inlined url for CSS
let urls_for_css = import_info.ast_urls_for_css[record.source_index.get() as usize];
if !urls_for_css.is_empty() {
- return Ok(urls_for_css);
+ return Ok(with_suffix(urls_for_css));
}
// It is a chunk URL
let unique_key_for_additional_file =
import_info.ast_unique_key_for_additional_file[record.source_index.get() as usize];
if !unique_key_for_additional_file.is_empty() {
- return Ok(unique_key_for_additional_file);
+ return Ok(with_suffix(unique_key_for_additional_file));
}
}
// External URL stays as-is
diff --git a/test/bundler/esbuild/css.test.ts b/test/bundler/esbuild/css.test.ts
index 504cc61ce975..0bb645f473da 100644
--- a/test/bundler/esbuild/css.test.ts
+++ b/test/bundler/esbuild/css.test.ts
@@ -1180,6 +1180,85 @@ b {
},
});
+ // A url()'s `?query` / `#fragment` suffix must survive path rewriting so that
+ // SVG fragment references like `url(sprites.svg#icon)` keep addressing the
+ // element inside the asset. esbuild also preserves these.
+ itBundled("css/URLQueryFragmentPreservedFileLoader", {
+ files: {
+ "/entry.css": /* css */ `
+ .a { mask: url(./sprites.svg#icon) }
+ .b { clip-path: url("./sprites.svg?v=3#c") }
+ .c { background: url(./sprites.svg) }
+ .d { --bg: url("./sprites.svg#custom") }
+ `,
+ "/sprites.svg": Buffer.alloc(128 * 1024 + 1, "Z").toString(),
+ },
+ loader: {
+ ".svg": "file",
+ },
+ outdir: "/out",
+ onAfterBundle(api) {
+ api.expectFile("/out/entry.css").toEqualIgnoringWhitespace(/* css */ `
+/* entry.css */
+.a {
+ mask: url("./sprites-mrrzcz3w.svg#icon");
+}
+.b {
+ clip-path: url("./sprites-mrrzcz3w.svg?v=3#c");
+}
+.c {
+ background: url("./sprites-mrrzcz3w.svg");
+}
+.d {
+ --bg: url("./sprites-mrrzcz3w.svg#custom");
+}
+`);
+ },
+ });
+ itBundled("css/URLQueryFragmentPreservedDataURL", {
+ files: {
+ "/entry.css": /* css */ `
+ .a { mask: url(./small.svg#m1) }
+ .b { background: url("./small.svg?v=2") }
+ .c { background: url(./small.svg) }
+ `,
+ "/small.svg": ``,
+ },
+ outdir: "/out",
+ onAfterBundle(api) {
+ api.expectFile("/out/entry.css").toEqualIgnoringWhitespace(/* css */ `
+/* entry.css */
+.a {
+ mask: url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciPjxtYXNrIGlkPSJtMSIvPjwvc3ZnPg==#m1");
+}
+.b {
+ background: url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciPjxtYXNrIGlkPSJtMSIvPjwvc3ZnPg==?v=2");
+}
+.c {
+ background: url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciPjxtYXNrIGlkPSJtMSIvPjwvc3ZnPg==");
+}
+`);
+ },
+ });
+ itBundled("css/URLQueryFragmentPreservedMinified", {
+ files: {
+ "/entry.css": /* css */ `
+ .a { mask: url(./sprites.svg#icon) }
+ `,
+ "/sprites.svg": Buffer.alloc(128 * 1024 + 1, "Z").toString(),
+ },
+ loader: {
+ ".svg": "file",
+ },
+ minifyWhitespace: true,
+ outdir: "/out",
+ onAfterBundle(api) {
+ api.expectFile("/out/entry.css").toEqualIgnoringWhitespace(
+ /* css */ `.a{mask:url(./sprites-mrrzcz3w.svg#icon)}`,
+ );
+ },
+ });
+
itBundled("css/IgnoreURLsInAtRulePrelude", {
// GENERATED
files: {
From b82d858b4419ed9df943a187b8c273eb7ff585eb Mon Sep 17 00:00:00 2001
From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com>
Date: Tue, 7 Jul 2026 16:28:11 +0000
Subject: [PATCH 2/3] [autofix.ci] apply automated fixes
---
test/bundler/esbuild/css.test.ts | 4 +---
1 file changed, 1 insertion(+), 3 deletions(-)
diff --git a/test/bundler/esbuild/css.test.ts b/test/bundler/esbuild/css.test.ts
index 0bb645f473da..cb9ededf1a6b 100644
--- a/test/bundler/esbuild/css.test.ts
+++ b/test/bundler/esbuild/css.test.ts
@@ -1253,9 +1253,7 @@ b {
minifyWhitespace: true,
outdir: "/out",
onAfterBundle(api) {
- api.expectFile("/out/entry.css").toEqualIgnoringWhitespace(
- /* css */ `.a{mask:url(./sprites-mrrzcz3w.svg#icon)}`,
- );
+ api.expectFile("/out/entry.css").toEqualIgnoringWhitespace(/* css */ `.a{mask:url(./sprites-mrrzcz3w.svg#icon)}`);
},
});
From 2ef395db3bb6a73e256f53ab0a75ef3bfb2bc956 Mon Sep 17 00:00:00 2001
From: robobun <117481402+robobun@users.noreply.github.com>
Date: Tue, 7 Jul 2026 16:52:51 +0000
Subject: [PATCH 3/3] css: drop ?query on the data-URL inline path, keep
#fragment only
Appending a ?query to a base64 data: URL places it in the body passed
to forgiving-base64-decode (the Fetch data-URL processor excludes only
the fragment when re-serializing), which fails on '?'. Keep only the
#fragment on the inline path; the file-loader path still gets the full
?query#fragment since that becomes a real HTTP URL.
---
src/css/printer.rs | 15 ++++++++++-----
test/bundler/esbuild/css.test.ts | 10 ++++++++--
2 files changed, 18 insertions(+), 7 deletions(-)
diff --git a/src/css/printer.rs b/src/css/printer.rs
index 59e0550c7b30..c360f5304382 100644
--- a/src/css/printer.rs
+++ b/src/css/printer.rs
@@ -424,7 +424,7 @@ impl<'a> Printer<'a> {
b""
};
let arena = self.arena;
- let with_suffix = |url: &'a [u8]| -> &'a [u8] {
+ let with_suffix = |url: &'a [u8], suffix: &[u8]| -> &'a [u8] {
if suffix.is_empty() {
return url;
}
@@ -433,16 +433,21 @@ impl<'a> Printer<'a> {
buf[url.len()..].copy_from_slice(suffix);
buf
};
- // It has an inlined url for CSS
+ // It has an inlined data: URL for CSS. A `?query` here lands in the
+ // base64 body and fails decoding, so keep only the `#fragment`.
let urls_for_css = import_info.ast_urls_for_css[record.source_index.get() as usize];
if !urls_for_css.is_empty() {
- return Ok(with_suffix(urls_for_css));
+ let fragment: &[u8] = match bun_core::strings::index_of_char(suffix, b'#') {
+ Some(i) => &suffix[i as usize..],
+ None => b"",
+ };
+ return Ok(with_suffix(urls_for_css, fragment));
}
- // It is a chunk URL
+ // It is a chunk URL (copied asset): keep the full `?query#fragment`.
let unique_key_for_additional_file =
import_info.ast_unique_key_for_additional_file[record.source_index.get() as usize];
if !unique_key_for_additional_file.is_empty() {
- return Ok(with_suffix(unique_key_for_additional_file));
+ return Ok(with_suffix(unique_key_for_additional_file, suffix));
}
}
// External URL stays as-is
diff --git a/test/bundler/esbuild/css.test.ts b/test/bundler/esbuild/css.test.ts
index cb9ededf1a6b..e450a5fcd60f 100644
--- a/test/bundler/esbuild/css.test.ts
+++ b/test/bundler/esbuild/css.test.ts
@@ -1220,21 +1220,27 @@ b {
"/entry.css": /* css */ `
.a { mask: url(./small.svg#m1) }
.b { background: url("./small.svg?v=2") }
- .c { background: url(./small.svg) }
+ .c { mask: url(./small.svg?v=2#m1) }
+ .d { background: url(./small.svg) }
`,
"/small.svg": ``,
},
outdir: "/out",
onAfterBundle(api) {
+ // A `?query` appended to a base64 data: URL lands in the body and fails
+ // decoding, so only the `#fragment` is kept on the inline path.
api.expectFile("/out/entry.css").toEqualIgnoringWhitespace(/* css */ `
/* entry.css */
.a {
mask: url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciPjxtYXNrIGlkPSJtMSIvPjwvc3ZnPg==#m1");
}
.b {
- background: url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciPjxtYXNrIGlkPSJtMSIvPjwvc3ZnPg==?v=2");
+ background: url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciPjxtYXNrIGlkPSJtMSIvPjwvc3ZnPg==");
}
.c {
+ mask: url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciPjxtYXNrIGlkPSJtMSIvPjwvc3ZnPg==#m1");
+}
+.d {
background: url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciPjxtYXNrIGlkPSJtMSIvPjwvc3ZnPg==");
}
`);