From 8750fbd4a7de1774322b52d53657ec4d00493f63 Mon Sep 17 00:00:00 2001 From: Pierre Brisorgueil Date: Fri, 10 Apr 2026 20:46:33 +0200 Subject: [PATCH 1/2] fix(home): isSvg handles URLs with query strings and fragments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Strip `?query` and `#fragment` from the img URL before the `.endsWith('.svg')` check so cache-busted paths like `/images/foo.svg?v=2` are still inlined via `v-html` and can resolve Vuetify theme CSS vars. Previously, any SVG with `?v=N` fell back to `` which rendered it as a raster-like `` whose internal `rgb(var(--v-theme-...))` references stayed unresolved — SVGs appeared solid black in light mode. Adds 8 unit tests covering `.svg`, `.svg?v=2`, `.svg?v=2&t=3`, `.svg#frag`, `.svg?v=2#frag`, absolute URLs with query, `.png`, `.png?v=2`. Fixes #3949 --- .../components/utils/home.img.component.vue | 6 ++- .../tests/home.img.component.unit.tests.js | 48 +++++++++++++++++++ 2 files changed, 53 insertions(+), 1 deletion(-) diff --git a/src/modules/home/components/utils/home.img.component.vue b/src/modules/home/components/utils/home.img.component.vue index 24f653ab1..a3760fb11 100644 --- a/src/modules/home/components/utils/home.img.component.vue +++ b/src/modules/home/components/utils/home.img.component.vue @@ -166,7 +166,11 @@ export default { * @returns {boolean} True when the source is a local SVG. */ isSvg() { - return this.img && this.img.toLowerCase().endsWith('.svg') && LOCAL_URL_RE.test(this.img); + if (!this.img) return false; + // Strip query string (?v=N cache-buster) and fragment (#id) before extension check + // so URLs like '/images/foo.svg?v=2' are still inlined. + const pathOnly = this.img.split('?')[0].split('#')[0].toLowerCase(); + return pathOnly.endsWith('.svg') && LOCAL_URL_RE.test(this.img); }, /** * Responsive height matching original v-img behaviour. diff --git a/src/modules/home/tests/home.img.component.unit.tests.js b/src/modules/home/tests/home.img.component.unit.tests.js index c92c7904e..645a42f18 100644 --- a/src/modules/home/tests/home.img.component.unit.tests.js +++ b/src/modules/home/tests/home.img.component.unit.tests.js @@ -296,6 +296,54 @@ describe('HomeImgComponent', () => { })); }); + describe('isSvg URL detection', () => { + /** + * Mount a component with a given img and return whether the inline SVG branch rendered. + * @param {string} img - Image URL to test. + * @returns {boolean} True if the inline SVG container is rendered. + */ + const mountsAsInlineSvg = (img) => { + fetch.mockReturnValueOnce(new Promise(() => {})); + const wrapper = mount(HomeImgComponent, { + props: { img }, + global: globalOpts(vuetify), + }); + return wrapper.find('.home-img-svg').exists(); + }; + + it('inlines plain local .svg', () => { + expect(mountsAsInlineSvg('/images/foo.svg')).toBe(true); + }); + + it('inlines local .svg with single query param (cache-buster)', () => { + expect(mountsAsInlineSvg('/images/foo.svg?v=2')).toBe(true); + }); + + it('inlines local .svg with multiple query params', () => { + expect(mountsAsInlineSvg('/images/foo.svg?v=2&t=3')).toBe(true); + }); + + it('inlines local .svg with fragment', () => { + expect(mountsAsInlineSvg('/images/foo.svg#frag')).toBe(true); + }); + + it('inlines local .svg with both query and fragment', () => { + expect(mountsAsInlineSvg('/images/foo.svg?v=2#frag')).toBe(true); + }); + + it('does NOT inline absolute SVG URLs (even with query string)', () => { + expect(mountsAsInlineSvg('https://cdn.example/foo.svg?v=5')).toBe(false); + }); + + it('does NOT inline non-SVG local paths', () => { + expect(mountsAsInlineSvg('/images/foo.png')).toBe(false); + }); + + it('does NOT inline non-SVG local paths with query string', () => { + expect(mountsAsInlineSvg('/images/foo.png?v=2')).toBe(false); + }); + }); + it('clears previous SVG when img changes', async () => { const url1 = uniqueSvgUrl(); const url2 = uniqueSvgUrl(); From 7aa6f8fb7f726fd1d10a6d80194601bc756efc12 Mon Sep 17 00:00:00 2001 From: Pierre Brisorgueil Date: Fri, 10 Apr 2026 20:50:18 +0200 Subject: [PATCH 2/2] test(home): unmount wrappers and resolve fetch in isSvg detection tests Addresses Copilot review on #3950: use an immediately-resolving { ok: false } fetch mock and unmount each wrapper to avoid leaking DOM/component instances across the isSvg URL detection tests. --- src/modules/home/tests/home.img.component.unit.tests.js | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/modules/home/tests/home.img.component.unit.tests.js b/src/modules/home/tests/home.img.component.unit.tests.js index 645a42f18..e97cafd87 100644 --- a/src/modules/home/tests/home.img.component.unit.tests.js +++ b/src/modules/home/tests/home.img.component.unit.tests.js @@ -299,16 +299,20 @@ describe('HomeImgComponent', () => { describe('isSvg URL detection', () => { /** * Mount a component with a given img and return whether the inline SVG branch rendered. + * Uses an immediately-resolving failed fetch so `fetchSvg()` settles synchronously + * and unmounts the wrapper before returning to avoid leaking DOM/instances between tests. * @param {string} img - Image URL to test. * @returns {boolean} True if the inline SVG container is rendered. */ const mountsAsInlineSvg = (img) => { - fetch.mockReturnValueOnce(new Promise(() => {})); + fetch.mockResolvedValueOnce({ ok: false }); const wrapper = mount(HomeImgComponent, { props: { img }, global: globalOpts(vuetify), }); - return wrapper.find('.home-img-svg').exists(); + const isInline = wrapper.find('.home-img-svg').exists(); + wrapper.unmount(); + return isInline; }; it('inlines plain local .svg', () => {