Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 47 additions & 10 deletions src/runtime/components/NuxtImg.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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.
* `<NuxtImg src="/logo.svg" format="png" />`), 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)

Expand All @@ -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
}
Expand Down Expand Up @@ -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)

Expand Down
4 changes: 2 additions & 2 deletions test/e2e/__snapshots__/ipx.json5
Original file line number Diff line number Diff line change
Expand Up @@ -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",
],
}
2 changes: 0 additions & 2 deletions test/e2e/generate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
]
`)
Expand Down
55 changes: 55 additions & 0 deletions test/nuxt/image.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof NuxtImg>['props']) => mount(NuxtImg, { props })

function setImageContext(options: Partial<CreateImageOptions>) {
Expand Down
2 changes: 1 addition & 1 deletion test/unit/bundle.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@
image: { provider: 'ipx' },
})

expect(roundToKilobytes(withImage.totalBytes - withoutImage.totalBytes)).toMatchInlineSnapshot(`"12.6k"`)
expect(roundToKilobytes(withImage.totalBytes - withoutImage.totalBytes)).toMatchInlineSnapshot(`"12.4k"`)

Check failure on line 24 in test/unit/bundle.test.ts

View workflow job for this annotation

GitHub Actions / ci (ubuntu-latest)

[unit] test/unit/bundle.test.ts > nuxt image bundle size > should match snapshot

Error: Snapshot `nuxt image bundle size > should match snapshot 1` mismatched Expected: ""12.4k"" Received: ""12.8k"" ❯ test/unit/bundle.test.ts:24:78
})
})

Expand Down
Loading