diff --git a/src/runtime/components/NuxtImg.vue b/src/runtime/components/NuxtImg.vue index 53ecad0eb..e62dac1be 100644 --- a/src/runtime/components/NuxtImg.vue +++ b/src/runtime/components/NuxtImg.vue @@ -47,12 +47,40 @@ defineSlots<{ default(props: DefaultSlotProps): any }>() const $img = useImage() const { providerOptions, normalizedAttrs, imageModifiers } = useImageProps(props) -const sizes = computed(() => $img.getSizes(props.src!, { - ...providerOptions.value, - sizes: props.sizes, - densities: props.densities, - modifiers: imageModifiers.value, -})) +/** + * Detect SVG sources that should bypass IPX raster processing. + * SVGs are vector images that don't benefit from resize, srcset density + * variants, or placeholder blur. Passing them through IPX causes crashes + * (svgo/css-tree bundler issues) and incorrect sizing. + * + * When the user explicitly requests format conversion (e.g. + * ``), we let the request + * through to IPX so the conversion can happen. + * + * @see https://github.com/nuxt/image/issues/2035 + * @see https://github.com/nuxt/image/issues/1967 + * @see https://github.com/nuxt/image/issues/2075 + */ +const isSvg = computed(() => { + if (!(/^[^?#]+\.svg(?:$|[?#])/i.test(props.src || ''))) { + return false + } + // Allow explicit format conversion (e.g. format="png") to pass through to IPX + const requestedFormat = props.format?.toLowerCase() + return !requestedFormat || requestedFormat === 'svg' +}) + +const sizes = computed(() => { + if (isSvg.value) { + return { src: props.src!, sizes: undefined, srcset: '' } + } + return $img.getSizes(props.src!, { + ...providerOptions.value, + sizes: props.sizes, + densities: props.densities, + modifiers: imageModifiers.value, + }) +}) const placeholderLoaded = ref(false) @@ -68,6 +96,12 @@ const imgAttrs = computed(() => ({ })) const placeholder = computed(() => { + // SVGs are lightweight vectors — no placeholder blur needed, and + // sending them through $img() would hit IPX which crashes on SVGs. + if (isSvg.value) { + return false + } + if (placeholderLoaded.value) { return false } @@ -95,11 +129,14 @@ const placeholder = computed(() => { }, providerOptions.value) }) -const mainSrc = computed(() => - props.sizes +const mainSrc = computed(() => { + if (isSvg.value) { + return props.src! + } + return props.sizes ? sizes.value.src - : $img(props.src!, imageModifiers.value, providerOptions.value), -) + : $img(props.src!, imageModifiers.value, providerOptions.value) +}) const src = computed(() => placeholder.value || mainSrc.value) diff --git a/test/e2e/__snapshots__/ipx.json5 b/test/e2e/__snapshots__/ipx.json5 index 9ed913a9d..2db218b1e 100644 --- a/test/e2e/__snapshots__/ipx.json5 +++ b/test/e2e/__snapshots__/ipx.json5 @@ -4,15 +4,15 @@ "/_ipx/s_300x300/images/colors-layer.jpg", "/_ipx/s_300x300/images/colors.jpg", "/_ipx/s_300x300/images/everest.jpg", - "/_ipx/s_300x300/images/tacos.svg", "/_ipx/s_300x300/unsplash/photo-1606112219348-204d7d8b94ee", + "/images/tacos.svg", ], "sources": [ "/_ipx/s_300x300/images/colors.jpg", "/_ipx/s_300x300/images/colors-layer.jpg", "/_ipx/s_300x300/images/colors-layer-config.jpg", "/_ipx/s_300x300/images/everest.jpg", - "/_ipx/s_300x300/images/tacos.svg", + "/images/tacos.svg", "/_ipx/s_300x300/unsplash/photo-1606112219348-204d7d8b94ee", ], } \ No newline at end of file diff --git a/test/e2e/generate.test.ts b/test/e2e/generate.test.ts index 4bb300721..d803b673a 100644 --- a/test/e2e/generate.test.ts +++ b/test/e2e/generate.test.ts @@ -39,13 +39,11 @@ describe('ipx provider', () => { "_ipx/s_300x300/images/colors-layer.jpg", "_ipx/s_300x300/images/colors.jpg", "_ipx/s_300x300/images/everest.jpg", - "_ipx/s_300x300/images/tacos.svg", "_ipx/s_300x300/unsplash/photo-1606112219348-204d7d8b94ee", "_ipx/s_600x600/images/colors-layer-config.jpg", "_ipx/s_600x600/images/colors-layer.jpg", "_ipx/s_600x600/images/colors.jpg", "_ipx/s_600x600/images/everest.jpg", - "_ipx/s_600x600/images/tacos.svg", "_ipx/s_600x600/unsplash/photo-1606112219348-204d7d8b94ee", ] `) diff --git a/test/nuxt/image.test.ts b/test/nuxt/image.test.ts index 60446a74a..c0b4750b9 100644 --- a/test/nuxt/image.test.ts +++ b/test/nuxt/image.test.ts @@ -605,6 +605,61 @@ describe('Renders NuxtImg with the custom prop and default slot', () => { }) }) +describe('SVG passthrough (#2035)', () => { + it('serves SVG as-is without IPX processing', () => { + const wrapper = mountImage({ src: '/logo.svg' }) + const img = wrapper.find('img') + expect(img.element.getAttribute('src')).toBe('/logo.svg') + expect(img.element.getAttribute('srcset')).toBeFalsy() + expect(img.element.getAttribute('sizes')).toBeFalsy() + }) + + it('handles SVG with query string', () => { + const wrapper = mountImage({ src: '/logo.svg?v=123' }) + expect(wrapper.find('img').element.getAttribute('src')).toBe('/logo.svg?v=123') + }) + + it('handles SVG with hash', () => { + const wrapper = mountImage({ src: '/logo.svg#icon' }) + expect(wrapper.find('img').element.getAttribute('src')).toBe('/logo.svg#icon') + }) + + it('is case-insensitive for .SVG extension', () => { + const wrapper = mountImage({ src: '/logo.SVG' }) + expect(wrapper.find('img').element.getAttribute('src')).toBe('/logo.SVG') + expect(wrapper.find('img').element.getAttribute('srcset')).toBeFalsy() + }) + + it('ignores sizes prop for SVGs', () => { + const wrapper = mountImage({ src: '/logo.svg', sizes: 'sm:100vw md:50vw' }) + const img = wrapper.find('img') + expect(img.element.getAttribute('src')).toBe('/logo.svg') + expect(img.element.getAttribute('srcset')).toBeFalsy() + }) + + it('ignores width/height modifiers for SVGs', () => { + const wrapper = mountImage({ src: '/logo.svg', width: 200, height: 200 }) + const img = wrapper.find('img') + expect(img.element.getAttribute('src')).toBe('/logo.svg') + expect(img.element.getAttribute('srcset')).toBeFalsy() + }) + + it('allows explicit format conversion (svg → png) through IPX', () => { + const wrapper = mountImage({ src: '/logo.svg', format: 'png', width: 200 }) + const img = wrapper.find('img') + const src = img.element.getAttribute('src') + expect(src).toContain('/_ipx/') + expect(src).toContain('f_png') + }) + + it('still processes non-SVG images normally', () => { + const wrapper = mountImage({ src: '/image.png', width: 200, sizes: '200' }) + const img = wrapper.find('img') + expect(img.element.getAttribute('src')).toContain('/_ipx/') + expect(img.element.getAttribute('srcset')).toBeTruthy() + }) +}) + const mountImage = (props: ComponentMountingOptions['props']) => mount(NuxtImg, { props }) function setImageContext(options: Partial) { diff --git a/test/unit/bundle.test.ts b/test/unit/bundle.test.ts index eea4df32d..955804eee 100644 --- a/test/unit/bundle.test.ts +++ b/test/unit/bundle.test.ts @@ -21,7 +21,7 @@ describe.skipIf(process.env.ECOSYSTEM_CI || isWindows)('nuxt image bundle size', image: { provider: 'ipx' }, }) - expect(roundToKilobytes(withImage.totalBytes - withoutImage.totalBytes)).toMatchInlineSnapshot(`"12.6k"`) + expect(roundToKilobytes(withImage.totalBytes - withoutImage.totalBytes)).toMatchInlineSnapshot(`"12.4k"`) }) })