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=="); } `);