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
4 changes: 2 additions & 2 deletions src/runtime/components/NuxtImg.vue
Original file line number Diff line number Diff line change
Expand Up @@ -49,8 +49,8 @@ const { providerOptions, normalizedAttrs, imageModifiers } = useImageProps(props

const sizes = computed(() => $img.getSizes(props.src!, {
...providerOptions.value,
sizes: props.sizes,
densities: props.densities,
...(props.sizes !== undefined ? { sizes: props.sizes } : {}),
...(props.densities !== undefined ? { densities: props.densities } : {}),
modifiers: imageModifiers.value,
}))

Expand Down
46 changes: 24 additions & 22 deletions src/runtime/components/NuxtPicture.vue
Original file line number Diff line number Diff line change
Expand Up @@ -110,8 +110,8 @@ const sources = computed<Source[]>(() => {
return formats.map((format) => {
const { srcset, sizes, src } = $img.getSizes(props.src!, {
...providerOptions.value,
sizes: props.sizes || $img.options.screens,
densities: props.densities,
...(props.sizes !== undefined ? { sizes: props.sizes } : {}),
...(props.densities !== undefined ? { densities: props.densities } : {}),
modifiers: { ...imageModifiers.value, format },
})

Expand All @@ -120,27 +120,29 @@ const sources = computed<Source[]>(() => {
})

if (import.meta.server && props.preload) {
useHead({ link: () => {
const firstSource = sources.value[0]
if (!firstSource) {
return []
}
useHead({
link: () => {
const firstSource = sources.value[0]
if (!firstSource) {
return []
}

const link: NonNullable<SerializableHead['link']>[number] = {
rel: 'preload',
as: 'image',
imagesrcset: firstSource.srcset,
nonce: props.nonce,
...(typeof props.preload !== 'boolean' && props.preload?.fetchPriority
? { fetchpriority: props.preload.fetchPriority }
: {}),
}
const link: NonNullable<SerializableHead['link']>[number] = {
rel: 'preload',
as: 'image',
imagesrcset: firstSource.srcset,
nonce: props.nonce,
...(typeof props.preload !== 'boolean' && props.preload?.fetchPriority
? { fetchpriority: props.preload.fetchPriority }
: {}),
}

if (sources.value?.[0]?.sizes) {
link.imagesizes = sources.value[0].sizes
}
return [link]
} })
if (sources.value?.[0]?.sizes) {
link.imagesizes = sources.value[0].sizes
}
return [link]
},
})
}

// Prerender static images
Expand All @@ -153,7 +155,7 @@ if (import.meta.server && import.meta.prerender) {
const nuxtApp = useNuxtApp()
const initialLoad = nuxtApp.isHydrating

const imgEl = useTemplateRef('imgEl')
const imgEl = useTemplateRef<HTMLImageElement>('imgEl')
onMounted(() => {
const el = Array.isArray(imgEl.value) ? imgEl.value[0] as HTMLImageElement | undefined : imgEl.value
if (!el) {
Expand Down
13 changes: 9 additions & 4 deletions src/runtime/image.ts
Original file line number Diff line number Diff line change
Expand Up @@ -125,10 +125,15 @@ function getPreset(ctx: ImageCTX, name?: string): ImageOptions {
}

function getSizes(ctx: ImageCTX, input: string, opts: ImageSizesOptions): ImageSizes {
const width = parseSize(opts.modifiers?.width)
const height = parseSize(opts.modifiers?.height)
const sizes = parseSizes(opts.sizes)
const densities = opts.densities?.trim() ? parseDensities(opts.densities.trim()) : ctx.options.densities
// Merge preset options so preset-provided sizes/densities are respected
const preset = getPreset(ctx, opts.preset)
const merged = defu({} as ImageSizesOptions, opts, preset)

const width = parseSize(merged.modifiers?.width)
const height = parseSize(merged.modifiers?.height)

const sizes = merged.sizes ? parseSizes(merged.sizes) : {}
const densities = merged.densities?.trim() ? parseDensities(merged.densities.trim()) : ctx.options.densities
checkDensities(densities)

const hwRatio = (width && height) ? height / width : 0
Expand Down
106 changes: 100 additions & 6 deletions test/nuxt/image.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import type { ComponentMountingOptions, VueWrapper } from '@vue/test-utils'
import { imageOptions } from '#build/image-options.mjs'
import { NuxtImg } from '#components'
import { createImage } from '@nuxt/image/runtime'
import { h, nextTick, useNuxtApp, useRuntimeConfig } from '#imports'
import { h, nextTick, useNuxtApp, useRuntimeConfig, useImage } from '#imports'

describe('Renders simple image', () => {
let wrapper: VueWrapper<any>
Expand Down Expand Up @@ -156,16 +156,16 @@ describe('Renders simple image', () => {
})
})

const getImageLoad = (cb = () => {}) => {
let resolve = () => {}
let reject = () => {}
const getImageLoad = (cb = () => { }) => {
let resolve = () => { }
let reject = () => { }
let image = {} as HTMLImageElement
const loadEvent = Symbol('loadEvent')
const errorEvent = Symbol('errorEvent')
const ImageMock = vi.fn(() => {
const _image = {
onload: () => {},
onerror: () => {},
onload: () => { },
onerror: () => { },
} as unknown as HTMLImageElement
image = _image
// @ts-expect-error not valid argument for onload
Expand Down Expand Up @@ -307,6 +307,100 @@ describe('Renders placeholder image', () => {
})
})

describe('Preset sizes and densities fix', () => {
it('Component renders correctly with undefined sizes prop', () => {
// This test verifies the fix that prevents undefined props from overriding preset configurations
// The fix changes the getSizes call to only include sizes/densities when explicitly defined
const img = mountImage({
src: '/image.png',
width: 300,
height: 400,
// sizes and densities are intentionally undefined
})

// The test passes if the component renders without errors
expect(img.find('img').exists()).toBe(true)
expect(img.find('img').element.getAttribute('src')).toContain('/image.png')
})
})

describe('Renders image with presets', () => {
const nuxtApp = useNuxtApp()
const config = useRuntimeConfig()
const src = '/image.png'

beforeEach(() => {
delete (nuxtApp as any)._img
delete (nuxtApp as any).$img
})

it('Uses preset sizes when component sizes prop is undefined', () => {
// Provide a preset that defines responsive sizes
const imgContext = createImage({
runtimeConfig: {} as any,
...imageOptions,
presets: {
...imageOptions.presets,
responsive: {
// Use responsive sizes format that will generate multiple widths
sizes: 'sm:320px,md:640px,lg:1024px',
},
},
nuxt: {
baseURL: config.app.baseURL,
},
})
; (nuxtApp as any)._img = imgContext
; (nuxtApp as any).$img = imgContext

// Call getSizes directly to validate preset merging
const { srcset } = useImage().getSizes(src, {
preset: 'responsive',
modifiers: { width: 300, height: 400 },
})
// Should generate width-based srcset (not density-based)
const widthDescriptors = srcset.match(/\b\d+w\b/g) || []
expect(widthDescriptors.length).toBeGreaterThanOrEqual(2)
// Should NOT contain density-based descriptors
expect(srcset).not.toMatch(/\s\d+x\b/)
})

it('Uses preset densities when component densities prop is undefined', () => {
// Provide a preset that defines densities
; (nuxtApp as any)._img = createImage({
runtimeConfig: {} as any,
...imageOptions,
presets: {
...imageOptions.presets,
highDensity: {
densities: '1x 2x 3x',
},
},
nuxt: {
baseURL: config.app.baseURL,
},
})

const img = mount(NuxtImg, {
propsData: {
src,
width: 300,
height: 400,
preset: 'highDensity',
// densities is intentionally undefined to test preset inheritance
},
})

const imgElement = img.find('img').element
const srcset = imgElement.getAttribute('srcset')

// Should use density-based srcset (presence of multiple `x` descriptors and no `w` descriptors)
const densityDescriptors = srcset?.match(/\s\d+x\b/g) || []
expect(densityDescriptors.length).toBeGreaterThanOrEqual(2)
expect(srcset).not.toMatch(/\b\d+w\b/)
})
})

describe('Renders image, applies module config', () => {
const nuxtApp = useNuxtApp()
const config = useRuntimeConfig()
Expand Down
Loading