diff --git a/src/runtime/image.ts b/src/runtime/image.ts index 764d32268..098b99f58 100644 --- a/src/runtime/image.ts +++ b/src/runtime/image.ts @@ -1,7 +1,7 @@ import { defu } from 'defu' import { hasProtocol, parseURL, joinURL, withLeadingSlash } from 'ufo' import { imageMeta } from './utils/meta' -import { checkDensities, parseDensities, parseSize, parseSizes } from './utils' +import { checkDensities, parseDensities, parseSize, parseSizes, SIZES_DEFAULT_KEY } from './utils' import { prerenderStaticImages } from './utils/prerender' import type { ImageOptions, ImageSizesOptions, CreateImageOptions, ResolvedImage, ImageCTX, $Img, ImageSizes, ImageSizesVariant, ConfiguredImageProviders } from '@nuxt/image' @@ -133,6 +133,30 @@ function getSizes(ctx: ImageCTX, input: string, opts: ImageSizesOptions): ImageS const height = parseSize(merged.modifiers?.height) const sizes = merged.sizes ? parseSizes(merged.sizes) : {} + + // Handle bare/default size values (e.g. `sizes="100vw"` or `sizes="200px"`). + // For fluid values (vw), the rendered pixel width depends on viewport size, + // so we must expand to all screen breakpoints for correct srcset generation. + // For fixed pixel values, we keep the original 1px sentinel which sorts + // before all breakpoints in finaliseSizeVariants. + // See: https://github.com/nuxt/image/issues/1433 + if (SIZES_DEFAULT_KEY in sizes) { + const defaultSize = sizes[SIZES_DEFAULT_KEY]! + delete sizes['default'] + if (defaultSize.endsWith('vw')) { + const screens = ctx.options.screens || {} + for (const screen in screens) { + if (!(screen in sizes)) { + sizes[screen] = defaultSize + } + } + } + else { + // Fixed pixel value: use 1px key so it sorts before all breakpoints + sizes['1px'] = defaultSize + } + } + const _densities = merged.densities?.trim() const densities = _densities ? parseDensities(_densities) : ctx.options.densities checkDensities(densities) diff --git a/src/runtime/utils/index.ts b/src/runtime/utils/index.ts index e6b021a91..de1e6a188 100644 --- a/src/runtime/utils/index.ts +++ b/src/runtime/utils/index.ts @@ -86,6 +86,15 @@ export function parseSize(input: string | number | undefined = '') { } } +/** + * Sentinel key used for bare size values without a breakpoint prefix + * (e.g. `"100vw"` in `sizes="100vw sm:50vw"`). Consumers should expand + * this to all configured screen breakpoints that aren't explicitly set. + * + * @see https://github.com/nuxt/image/issues/1433 + */ +export const SIZES_DEFAULT_KEY = 'default' + export function parseSizes(input: Record | string): Record { const sizes: Record = {} // string => object @@ -93,7 +102,7 @@ export function parseSizes(input: Record | string): Rec for (const entry of input.split(/[\s,]+/).filter(e => e)) { const s = entry.split(':') if (s.length !== 2) { - sizes['1px'] = s[0]!.trim() + sizes[SIZES_DEFAULT_KEY] = s[0]!.trim() } else { sizes[s[0]!.trim()] = s[1]!.trim() diff --git a/test/nuxt/image.test.ts b/test/nuxt/image.test.ts index 60446a74a..050f5426c 100644 --- a/test/nuxt/image.test.ts +++ b/test/nuxt/image.test.ts @@ -390,6 +390,45 @@ describe('Sizes and densities behavior', () => { // Should have sizes attribute expect(sizes).toBeTruthy() }) + + it('bare vw sizes value generates proper srcset widths (#1433)', () => { + const img = mountImage({ + src: '/image.png', + width: 300, + height: 400, + sizes: '100vw', + }) + + const imgElement = img.find('img').element + const srcset = imgElement.getAttribute('srcset')! + const sizes = imgElement.getAttribute('sizes') + + // Should generate width-based srcset entries matching screen breakpoints, + // not 1w/2w entries from a 1px placeholder + const widths = srcset.match(/\b(\d+)w\b/g)!.map(w => Number.parseInt(w)) + expect(widths.every(w => w > 100)).toBe(true) + + // Should have sizes attribute with media queries + expect(sizes).toBeTruthy() + expect(sizes).toContain('100vw') + }) + + it('bare vw sizes with explicit breakpoints fills remaining screens (#1433)', () => { + const img = mountImage({ + src: '/image.png', + width: 300, + height: 400, + sizes: '100vw lg:480px', + }) + + const imgElement = img.find('img').element + const srcset = imgElement.getAttribute('srcset')! + + // lg:480px should produce a 480w entry, other screens should use 100vw + expect(srcset).toContain('480w') + const widths = srcset.match(/\b(\d+)w\b/g)!.map(w => Number.parseInt(w)) + expect(widths.every(w => w > 100)).toBe(true) + }) }) describe('Preset sizes and densities inheritance', () => { diff --git a/test/unit/bundle.test.ts b/test/unit/bundle.test.ts index eea4df32d..8ba3ada0f 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.8k"`) }) })