From b27e17d8b32d438bbb3bff4d3613ac67bc98ff86 Mon Sep 17 00:00:00 2001 From: Maxim Tyminko <32540212+tyminko@users.noreply.github.com> Date: Sun, 10 Aug 2025 17:02:58 +0200 Subject: [PATCH] fix: apply preset sizes when component sizes prop is undefined - Enhanced getSizes function to properly merge preset options using defu - Updated NuxtImg and NuxtPicture components to only pass sizes/densities when explicitly defined - Added tests to verify preset sizes and densities are correctly applied - Fixes issue where undefined props override preset values causing incorrect srcset generation Resolves density-based fallback when width-based srcset should be used from presets Fixes #1918 --- src/runtime/components/NuxtImg.vue | 4 +- src/runtime/components/NuxtPicture.vue | 46 ++++++----- src/runtime/image.ts | 13 ++- test/nuxt/image.test.ts | 106 +++++++++++++++++++++++-- 4 files changed, 135 insertions(+), 34 deletions(-) diff --git a/src/runtime/components/NuxtImg.vue b/src/runtime/components/NuxtImg.vue index d9850ef04..f7a1a9275 100644 --- a/src/runtime/components/NuxtImg.vue +++ b/src/runtime/components/NuxtImg.vue @@ -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, })) diff --git a/src/runtime/components/NuxtPicture.vue b/src/runtime/components/NuxtPicture.vue index e786af260..4bb62a525 100644 --- a/src/runtime/components/NuxtPicture.vue +++ b/src/runtime/components/NuxtPicture.vue @@ -110,8 +110,8 @@ const sources = computed(() => { 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 }, }) @@ -120,27 +120,29 @@ const sources = computed(() => { }) 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[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[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 @@ -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('imgEl') onMounted(() => { const el = Array.isArray(imgEl.value) ? imgEl.value[0] as HTMLImageElement | undefined : imgEl.value if (!el) { diff --git a/src/runtime/image.ts b/src/runtime/image.ts index 900598841..9da39d3e3 100644 --- a/src/runtime/image.ts +++ b/src/runtime/image.ts @@ -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 diff --git a/test/nuxt/image.test.ts b/test/nuxt/image.test.ts index 8d0da374a..8763111ef 100644 --- a/test/nuxt/image.test.ts +++ b/test/nuxt/image.test.ts @@ -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 @@ -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 @@ -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()