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
26 changes: 25 additions & 1 deletion src/runtime/image.ts
Original file line number Diff line number Diff line change
@@ -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'

Expand Down Expand Up @@ -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)
Expand Down
11 changes: 10 additions & 1 deletion src/runtime/utils/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,14 +86,23 @@ 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, string | number> | string): Record<string, string> {
const sizes: Record<string, string> = {}
// string => object
if (typeof input === 'string') {
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()
Expand Down
39 changes: 39 additions & 0 deletions test/nuxt/image.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
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 @@ 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"`)
})
})

Expand Down
Loading