From 9af42b811b3bfd859fe98b1a7a08c61e334a9e82 Mon Sep 17 00:00:00 2001 From: David Kay Date: Sat, 21 Mar 2026 12:59:12 -0500 Subject: [PATCH 1/2] fix: expand bare vw sizes to all screen breakpoints (#1433) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When `sizes="100vw"` is used without a breakpoint prefix, parseSizes mapped it to a `1px` key. The fluid width calculation then produced `Math.round((100/100) * 1) = 1`, generating useless 1w/2w srcset entries instead of proper viewport-scaled widths. Bare fluid values (ending in `vw`) are now expanded to all configured screen breakpoints that aren't explicitly specified, so the vw→px calculation uses each screen's actual width. Fixed pixel values keep the original 1px sentinel behavior which was already correct. Examples of corrected output: - `sizes="100vw"` → srcset with 640w, 768w, 1024w, 1280w, 1536w - `sizes="50vw"` → srcset with 320w, 384w, 512w, 640w, 768w - `sizes="100vw lg:480px"` → 100vw for all screens except lg which gets 480px - `sizes="200,500:500,900:900"` → unchanged (fixed px, not affected) Closes #1433 --- src/runtime/image.ts | 26 ++++++++++++++++++++++++- src/runtime/utils/index.ts | 11 ++++++++++- test/nuxt/image.test.ts | 39 ++++++++++++++++++++++++++++++++++++++ test/unit/bundle.test.ts | 2 +- 4 files changed, 75 insertions(+), 3 deletions(-) diff --git a/src/runtime/image.ts b/src/runtime/image.ts index 764d32268..454860a52 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[SIZES_DEFAULT_KEY] + 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"`) }) }) From 972d0c60ffe6a91929e5eeb55a1bb87db2318f02 Mon Sep 17 00:00:00 2001 From: David Kay Date: Sat, 21 Mar 2026 13:01:50 -0500 Subject: [PATCH 2/2] fix: use literal key in delete to satisfy no-dynamic-delete lint rule --- src/runtime/image.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/runtime/image.ts b/src/runtime/image.ts index 454860a52..098b99f58 100644 --- a/src/runtime/image.ts +++ b/src/runtime/image.ts @@ -142,7 +142,7 @@ function getSizes(ctx: ImageCTX, input: string, opts: ImageSizesOptions): ImageS // See: https://github.com/nuxt/image/issues/1433 if (SIZES_DEFAULT_KEY in sizes) { const defaultSize = sizes[SIZES_DEFAULT_KEY]! - delete sizes[SIZES_DEFAULT_KEY] + delete sizes['default'] if (defaultSize.endsWith('vw')) { const screens = ctx.options.screens || {} for (const screen in screens) {