Repository: nuxt/image
Pull request #872 (2023-06-14 22:04:49)

fix: address miscellaneous bugs with new 'densities' prop
### Addresses
- [x] Do not fail for single entries of 'sizes' prop
- [x] Do not fail for _non-breakpoint_ entries of 'sizes' prop
- [x] No longer ignore _non-breakpoint_ entries of 'sizes' prop but add to final `img.sizes`
- [x] Fallback to global `image.densities` config when empty/blank string passed as prop
- [x] Error for invalid values passed to 'densities' prop

netlify[bot]: ### <span aria-hidden="true">👷</span> Deploy Preview for *nuxt-image* processing.


|  Name | Link |
|:-:|------------------------|
|<span aria-hidden="true">🔨</span> Latest commit | 179b6e5434331e2e5a45fd189f57cf5d9826c812 |
|<span aria-hidden="true">🔍</span> Latest deploy log | https://app.netlify.com/sites/nuxt-image/deploys/648a3983033bfc0008ed9009 |

Patch:
diff --git a/docs/content/5.providers/cloudflare.md b/docs/content/5.providers/cloudflare.md
index f4f5870..442d94a 100644
--- a/docs/content/5.providers/cloudflare.md
+++ b/docs/content/5.providers/cloudflare.md
@@ -4,10 +4,6 @@ Nuxt Image has first class integration with Cloudflare
 
 Integration between [Cloudflare](https://developers.cloudflare.com/images/) and the image module.
 
-::alert{type="info"}
-Availability only for Business and Enterprise Customers. Normally Cloudflare [pages](https://pages.cloudflare.com/) users can already benefit from build-time image optimization.
-::
-
 To use this provider you just need to specify the base url (zone) of your service:
 
 ```ts [nuxt.config.ts]
diff --git a/package.json b/package.json
index 09ab52c..39b191b 100755
--- a/package.json
+++ b/package.json
@@ -52,7 +52,7 @@
     "jiti": "1.18.2",
     "nuxt": "^3.5.3",
     "nuxt-vitest": "^0.8.5",
-    "playwright": "^1.35.0",
+    "playwright": "^1.35.1",
     "typescript": "5.1.3",
     "vitest": "^0.32.0",
     "vue-tsc": "^1.6.5"
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index e8d6a5a..a450547 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -55,7 +55,7 @@ importers:
         version: 0.4.0(@nuxt/kit@3.5.3)(nuxi@3.5.3)
       '@nuxt/test-utils':
         specifier: ^3.5.3
-        version: 3.5.3(playwright@1.35.0)(rollup@3.23.0)(vitest@0.32.0)(vue@3.3.4)
+        version: 3.5.3(playwright@1.35.1)(rollup@3.23.0)(vitest@0.32.0)(vue@3.3.4)
       '@nuxtjs/eslint-config-typescript':
         specifier: ^12.0.0
         version: 12.0.0(eslint@8.42.0)(typescript@5.1.3)
@@ -87,14 +87,14 @@ importers:
         specifier: ^0.8.5
         version: 0.8.5(@vitejs/plugin-vue-jsx@3.0.1)(@vitejs/plugin-vue@4.2.3)(rollup@3.23.0)(vite@4.3.9)(vitest@0.32.0)(vue-router@4.2.2)(vue@3.3.4)
       playwright:
-        specifier: ^1.35.0
-        version: 1.35.0
+        specifier: ^1.35.1
+        version: 1.35.1
       typescript:
         specifier: 5.1.3
         version: 5.1.3
       vitest:
         specifier: ^0.32.0
-        version: 0.32.0(playwright@1.35.0)
+        version: 0.32.0(playwright@1.35.1)
       vue-tsc:
         specifier: ^1.6.5
         version: 1.6.5(typescript@5.1.3)
@@ -1085,7 +1085,7 @@ packages:
       - supports-color
     dev: true
 
-  /@nuxt/test-utils@3.5.3(playwright@1.35.0)(rollup@3.23.0)(vitest@0.32.0)(vue@3.3.4):
+  /@nuxt/test-utils@3.5.3(playwright@1.35.1)(rollup@3.23.0)(vitest@0.32.0)(vue@3.3.4):
     resolution: {integrity: sha512-ujkOrn3Qvd+6TREsg4h6Vm1YbPgwl70qh9s6jQiEIGhaH9wqXUUgbHEdWelUMeRO+jbV1X4rtd7Sdww/rfR5lA==}
     engines: {node: ^14.18.0 || >=16.10.0}
     peerDependencies:
@@ -1109,9 +1109,9 @@ packages:
       get-port-please: 3.0.1
       ofetch: 1.0.1
       pathe: 1.1.1
-      playwright: 1.35.0
+      playwright: 1.35.1
       ufo: 1.1.2
-      vitest: 0.32.0(playwright@1.35.0)
+      vitest: 0.32.0(playwright@1.35.1)
       vue: 3.3.4
     transitivePeerDependencies:
       - rollup
@@ -1741,7 +1741,7 @@ packages:
       magic-string: 0.30.0
       picocolors: 1.0.0
       std-env: 3.3.3
-      vitest: 0.32.0(playwright@1.35.0)
+      vitest: 0.32.0(playwright@1.35.1)
     dev: true
 
   /@vitest/expect@0.32.0:
@@ -6049,7 +6049,7 @@ packages:
       perfect-debounce: 1.0.0
       std-env: 3.3.3
       vite: 4.3.9(@types/node@18.16.18)
-      vitest: 0.32.0(playwright@1.35.0)
+      vitest: 0.32.0(playwright@1.35.1)
       vitest-environment-nuxt: 0.8.5(rollup@3.23.0)(vitest@0.32.0)(vue-router@4.2.2)(vue@3.3.4)
     transitivePeerDependencies:
       - rollup
@@ -6519,19 +6519,19 @@ packages:
     engines: {node: '>=10'}
     dev: true
 
-  /playwright-core@1.35.0:
-    resolution: {integrity: sha512-muMXyPmIx/2DPrCHOD1H1ePT01o7OdKxKj2ebmCAYvqhUy+Y1bpal7B0rdoxros7YrXI294JT/DWw2LqyiqTPA==}
+  /playwright-core@1.35.1:
+    resolution: {integrity: sha512-pNXb6CQ7OqmGDRspEjlxE49w+4YtR6a3X6mT1hZXeJHWmsEz7SunmvZeiG/+y1yyMZdHnnn73WKYdtV1er0Xyg==}
     engines: {node: '>=16'}
     hasBin: true
     dev: true
 
-  /playwright@1.35.0:
-    resolution: {integrity: sha512-xhFhsoBmKPQfj3dM+HbIiFVlqRCZp2rwdJd/QFd9YBuidabo3TkVv0iuxPQ4vZoMwtSI7qzjY93f5ohdC97hww==}
+  /playwright@1.35.1:
+    resolution: {integrity: sha512-NbwBeGJLu5m7VGM0+xtlmLAH9VUfWwYOhUi/lSEDyGg46r1CA9RWlvoc5yywxR9AzQb0mOCm7bWtOXV7/w43ZA==}
     engines: {node: '>=16'}
     hasBin: true
     requiresBuild: true
     dependencies:
-      playwright-core: 1.35.0
+      playwright-core: 1.35.1
     dev: true
 
   /pluralize@8.0.0:
@@ -8571,7 +8571,7 @@ packages:
       magic-string: 0.30.0
       ofetch: 1.0.1
       unenv: 1.5.1
-      vitest: 0.32.0(playwright@1.35.0)
+      vitest: 0.32.0(playwright@1.35.1)
       vue: 3.3.4
       vue-router: 4.2.2(vue@3.3.4)
     transitivePeerDependencies:
@@ -8579,7 +8579,7 @@ packages:
       - supports-color
     dev: true
 
-  /vitest@0.32.0(playwright@1.35.0):
+  /vitest@0.32.0(playwright@1.35.1):
     resolution: {integrity: sha512-SW83o629gCqnV3BqBnTxhB10DAwzwEx3z+rqYZESehUB+eWsJxwcBQx7CKy0otuGMJTYh7qCVuUX23HkftGl/Q==}
     engines: {node: '>=v14.18.0'}
     hasBin: true
@@ -8628,7 +8628,7 @@ packages:
       magic-string: 0.30.0
       pathe: 1.1.1
       picocolors: 1.0.0
-      playwright: 1.35.0
+      playwright: 1.35.1
       std-env: 3.3.3
       strip-literal: 1.0.1
       tinybench: 2.5.0
diff --git a/src/runtime/components/nuxt-img.ts b/src/runtime/components/nuxt-img.ts
index 878e8c6..72d71fb 100644
--- a/src/runtime/components/nuxt-img.ts
+++ b/src/runtime/components/nuxt-img.ts
@@ -39,19 +39,9 @@ export default defineComponent({
 
     const attrs = computed(() => {
       const attrs: AttrsT = { ..._base.attrs.value, 'data-nuxt-img': '' }
-      if (props.sizes && (!props.placeholder || placeholderLoaded.value)) {
+      if (!props.placeholder || placeholderLoaded.value) {
         attrs.sizes = sizes.value.sizes
         attrs.srcset = sizes.value.srcset
-      } else if (props.densities || $img.options.densities) {
-        attrs.srcset = $img.getDensitySet(props.src!, {
-          ..._base.options.value,
-          densities: props.densities,
-          modifiers: {
-            ..._base.modifiers.value,
-            width: parseSize(props.width),
-            height: parseSize(props.height)
-          }
-        })
       }
       return attrs
     })
@@ -113,7 +103,7 @@ export default defineComponent({
         const img = new Image()
         img.src = mainSrc.value
         if (props.sizes) {
-          img.sizes = sizes.value.sizes
+          img.sizes = sizes.value.sizes || ''
           img.srcset = sizes.value.srcset
         }
         img.onload = (event) => {
diff --git a/src/runtime/image.ts b/src/runtime/image.ts
index fed0edf..70fdbed 100644
--- a/src/runtime/image.ts
+++ b/src/runtime/image.ts
@@ -1,8 +1,9 @@
 import { defu } from 'defu'
 import { hasProtocol, parseURL, joinURL, withLeadingSlash } from 'ufo'
 import type { ImageOptions, ImageSizesOptions, CreateImageOptions, ResolvedImage, ImageCTX, $Img } from '../types/image'
+import { ImageSizes, ImageSizesVariant } from '../types/image'
 import { imageMeta } from './utils/meta'
-import { parseDensities, parseSize } from './utils'
+import { parseDensities, parseSize, parseSizes } from './utils'
 import { prerenderStaticImages } from './utils/prerender'
 
 export function createImage (globalOptions: CreateImageOptions) {
@@ -37,7 +38,6 @@ export function createImage (globalOptions: CreateImageOptions) {
   $img.getImage = getImage
   $img.getMeta = ((input: string, options?: ImageOptions) => getMeta(ctx, input, options)) as $Img['getMeta']
   $img.getSizes = ((input: string, options: ImageSizesOptions) => getSizes(ctx, input, options)) as $Img['getSizes']
-  $img.getDensitySet = ((input: string, options: ImageSizesOptions) => getDensitySet(ctx, input, options)) as $Img['getDensitySet']
 
   ctx.$img = $img as $Img
 
@@ -127,65 +127,119 @@ function getPreset (ctx: ImageCTX, name?: string): ImageOptions {
   return ctx.options.presets[name]
 }
 
-function getSizes (ctx: ImageCTX, input: string, opts: ImageSizesOptions) {
+function getSizes (ctx: ImageCTX, input: string, opts: ImageSizesOptions): ImageSizes {
   const width = parseSize(opts.modifiers?.width)
   const height = parseSize(opts.modifiers?.height)
-  const densities = opts.densities ? parseDensities(opts.densities) : ctx.options.densities
+  const sizes = parseSizes(opts.sizes)
+  const densities = opts.densities?.trim() ? parseDensities(opts.densities.trim()) : ctx.options.densities
+  if (densities.length === 0) {
+    throw new Error('\'densities\' must not be empty, configure to \'1\' to render regular size only (DPR 1.0)')
+  }
 
   const hwRatio = (width && height) ? height / width : 0
   const sizeVariants = []
   const srcsetVariants = []
 
-  const sizes: Record<string, string> = {}
-
-  // string => object
-  if (typeof opts.sizes === 'string') {
-    for (const entry of opts.sizes.split(/[\s,]+/).filter(e => e)) {
-      const s = entry.split(':')
-      if (s.length !== 2) { continue }
-      sizes[s[0].trim()] = s[1].trim()
-    }
-  } else {
-    Object.assign(sizes, opts.sizes)
-  }
-
-  for (const key in sizes) {
-    const screenMaxWidth = (ctx.options.screens && ctx.options.screens[key]) || parseInt(key)
-    let size = String(sizes[key])
-    const isFluid = size.endsWith('vw')
-    if (!isFluid && /^\d+$/.test(size)) {
-      size = size + 'px'
-    }
-    if (!isFluid && !size.endsWith('px')) {
-      continue
-    }
-    let _cWidth = parseInt(size)
-    if (!screenMaxWidth || !_cWidth) {
-      continue
-    }
-    if (isFluid) {
-      _cWidth = Math.round((_cWidth / 100) * screenMaxWidth)
-    }
-    const _cHeight = hwRatio ? Math.round(_cWidth * hwRatio) : height
+  if (Object.keys(sizes).length > 1) {
+    // 'sizes path'
+    for (const key in sizes) {
+      const variant = getSizesVariant(key, String(sizes[key]), height, hwRatio, ctx)
+      if (variant === undefined) {
+        continue
+      }
 
-    // add size variant with 'media'
-    sizeVariants.push({
-      size,
-      screenMaxWidth,
-      media: `(max-width: ${screenMaxWidth}px)`
-    })
+      // add size variant with 'media'
+      sizeVariants.push({
+        size: variant.size,
+        screenMaxWidth: variant.screenMaxWidth,
+        media: `(max-width: ${variant.screenMaxWidth}px)`
+      })
 
-    if (densities) {
       // add srcset variants for all densities (for current 'size' processed)
       for (const density of densities) {
         srcsetVariants.push({
-          width: _cWidth * density,
-          src: ctx.$img!(input, { ...opts.modifiers, width: _cWidth * density, height: _cHeight ? _cHeight * density : undefined }, opts)
+          width: variant._cWidth * density,
+          src: getVariantSrc(ctx, input, opts, variant, density)
         })
       }
     }
+
+    finaliseSizeVariants(sizeVariants)
+  } else {
+    // 'densities path'
+    for (const density of densities) {
+      const key = Object.keys(sizes)[0]
+      let variant = getSizesVariant(key, String(sizes[key]), height, hwRatio, ctx)
+
+      // unable to resolve variant, fallback to default modifiers
+      if (variant === undefined) {
+        variant = {
+          size: '',
+          screenMaxWidth: 0,
+          _cWidth: opts.modifiers?.width as number,
+          _cHeight: opts.modifiers?.height as number
+        }
+      }
+
+      srcsetVariants.push({
+        width: density,
+        src: getVariantSrc(ctx, input, opts, variant, density)
+      })
+    }
+  }
+
+  finaliseSrcsetVariants(srcsetVariants)
+
+  // use last (:= largest) srcset variant as the image `src`
+  const defaultVariant = srcsetVariants[srcsetVariants.length - 1]
+
+  const sizesVal = sizeVariants.length ? sizeVariants.map(v => `${v.media ? v.media + ' ' : ''}${v.size}`).join(', ') : undefined
+  const suffix = sizesVal ? 'w' : 'x'
+  const srcsetVal = srcsetVariants.map(v => `${v.src} ${v.width}${suffix}`).join(', ')
+
+  return {
+    sizes: sizesVal,
+    srcset: srcsetVal,
+    src: defaultVariant?.src
+  }
+}
+
+function getSizesVariant (key: string, size: string, height: number | undefined, hwRatio: number, ctx: ImageCTX): ImageSizesVariant | undefined {
+  const screenMaxWidth = (ctx.options.screens && ctx.options.screens[key]) || parseInt(key)
+  const isFluid = size.endsWith('vw')
+  if (!isFluid && /^\d+$/.test(size)) {
+    size = size + 'px'
   }
+  if (!isFluid && !size.endsWith('px')) {
+    return undefined
+  }
+  let _cWidth = parseInt(size)
+  if (!screenMaxWidth || !_cWidth) {
+    return undefined
+  }
+  if (isFluid) {
+    _cWidth = Math.round((_cWidth / 100) * screenMaxWidth)
+  }
+  const _cHeight = hwRatio ? Math.round(_cWidth * hwRatio) : height
+  return {
+    size,
+    screenMaxWidth,
+    _cWidth,
+    _cHeight
+  }
+}
+
+function getVariantSrc (ctx: ImageCTX, input: string, opts: ImageSizesOptions, variant: ImageSizesVariant, density: number) {
+  return ctx.$img!(input,
+    {
+      ...opts.modifiers,
+      width: variant._cWidth ? variant._cWidth * density : undefined,
+      height: variant._cHeight ? variant._cHeight * density : undefined
+    },
+    opts)
+}
 
+function finaliseSizeVariants (sizeVariants: any[]) {
   sizeVariants.sort((v1, v2) => v1.screenMaxWidth - v2.screenMaxWidth)
 
   // for last size variant, always remove `media` (convention)
@@ -202,7 +256,10 @@ function getSizes (ctx: ImageCTX, input: string, opts: ImageSizesOptions) {
     }
     previousMedia = sizeVariant.media
   }
+}
 
+function finaliseSrcsetVariants (srcsetVariants: any[]) {
+  // sort by width
   srcsetVariants.sort((v1, v2) => v1.width - v2.width)
 
   // de-duplicate srcset variants (by key `width`)
@@ -214,38 +271,4 @@ function getSizes (ctx: ImageCTX, input: string, opts: ImageSizesOptions) {
     }
     previousWidth = sizeVariant.width
   }
-
-  // use last (:= largest) srcset variant as the image `src`
-  const defaultVar = srcsetVariants[srcsetVariants.length - 1]
-
-  return {
-    sizes: sizeVariants.map(v => `${v.media ? v.media + ' ' : ''}${v.size}`).join(', '),
-    srcset: srcsetVariants.map(v => `${v.src} ${v.width}w`).join(', '),
-    src: defaultVar?.src
-  }
-}
-
-function getDensitySet (ctx: ImageCTX, input: string, opts: ImageSizesOptions): string | undefined {
-  const densities = opts.densities ? parseDensities(opts.densities) : ctx.options.densities
-  if (densities.length === 0) {
-    return undefined
-  }
-
-  const srcsetVariants: Array<{ density: string, src: string }> = []
-
-  for (const density of densities) {
-    const modifiers = { ...opts.modifiers }
-    if (modifiers.width) {
-      modifiers.width = modifiers.width * density
-    }
-    if (modifiers.height) {
-      modifiers.height = modifiers.height * density
-    }
-    srcsetVariants.push({
-      density: `${density}x`,
-      src: ctx.$img!(input, modifiers, opts)
-    })
-  }
-
-  return srcsetVariants.map(v => `${v.src} ${v.density}`).join(', ')
 }
diff --git a/src/runtime/utils/index.ts b/src/runtime/utils/index.ts
index 3c7ddb6..f1612a0 100644
--- a/src/runtime/utils/index.ts
+++ b/src/runtime/utils/index.ts
@@ -102,3 +102,21 @@ export function parseDensities (input: string | undefined = ''): number[] {
   // de-duplicate and return
   return densities.filter((value, index) => densities.indexOf(value) === index)
 }
+
+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[s[0].trim()] = s[0].trim()
+      } else {
+        sizes[s[0].trim()] = s[1].trim()
+      }
+    }
+  } else {
+    Object.assign(sizes, input)
+  }
+  return sizes
+}
diff --git a/src/types/image.ts b/src/types/image.ts
index 3da7328..cddc056 100644
--- a/src/types/image.ts
+++ b/src/types/image.ts
@@ -60,7 +60,7 @@ export interface ResolvedImage {
 
 export interface ImageSizes {
   srcset: string
-  sizes: string
+  sizes: string | undefined
   src: string
 }
 
@@ -107,3 +107,10 @@ export interface OperationGeneratorConfig {
 }
 
 export type MapToStatic = (image: ResolvedImage, input: string) => string
+
+export interface ImageSizesVariant {
+  size?: string
+  screenMaxWidth: number
+  _cWidth: number
+  _cHeight?: number | undefined
+}


Test patch:
diff --git a/test/unit/image.test.ts b/test/unit/image.test.ts
index 02ea192..608c48d 100644
--- a/test/unit/image.test.ts
+++ b/test/unit/image.test.ts
@@ -19,7 +19,7 @@ describe('Renders simple image', () => {
   })
 
   it('Matches snapshot', () => {
-    expect(wrapper.html()).toMatchInlineSnapshot('"<img src=\\"/_ipx/s_1800x1800/image.png\\" width=\\"200\\" height=\\"200\\" data-nuxt-img=\\"\\" sizes=\\"(max-width: 500px) 500px, 900px\\" srcset=\\"/_ipx/s_500x500/image.png 500w, /_ipx/s_900x900/image.png 900w, /_ipx/s_1000x1000/image.png 1000w, /_ipx/s_1800x1800/image.png 1800w\\">"')
+    expect(wrapper.html()).toMatchInlineSnapshot('"<img src=\\"/_ipx/s_1800x1800/image.png\\" width=\\"200\\" height=\\"200\\" data-nuxt-img=\\"\\" sizes=\\"(max-width: 200px) 200px, (max-width: 500px) 500px, 900px\\" srcset=\\"/_ipx/s_200x200/image.png 200w, /_ipx/s_400x400/image.png 400w, /_ipx/s_500x500/image.png 500w, /_ipx/s_900x900/image.png 900w, /_ipx/s_1000x1000/image.png 1000w, /_ipx/s_1800x1800/image.png 1800w\\">"')
   })
 
   it('props.src is picked up by getImage()', () => {
@@ -38,7 +38,7 @@ describe('Renders simple image', () => {
 
   it('sizes', () => {
     const sizes = wrapper.find('img').element.getAttribute('sizes')
-    expect(sizes).toBe('(max-width: 500px) 500px, 900px')
+    expect(sizes).toBe('(max-width: 200px) 200px, (max-width: 500px) 500px, 900px')
   })
 
   it('applies densities', () => {
@@ -52,6 +52,57 @@ describe('Renders simple image', () => {
     expect(img.html()).toMatchInlineSnapshot('"<img src=\\"/_ipx/s_1200x1800/image.png\\" width=\\"200\\" height=\\"300\\" data-nuxt-img=\\"\\" sizes=\\"(max-width: 300px) 300px, 400px\\" srcset=\\"/_ipx/s_300x450/image.png 300w, /_ipx/s_400x600/image.png 400w, /_ipx/s_600x900/image.png 600w, /_ipx/s_800x1200/image.png 800w, /_ipx/s_900x1350/image.png 900w, /_ipx/s_1200x1800/image.png 1200w\\">"')
   })
 
+  it('empty densities (fallback to global)', () => {
+    const img = mountImage({
+      width: 200,
+      height: 300,
+      sizes: '300:300px,400:400px',
+      densities: '',
+      src: 'image.png'
+    })
+    expect(img.html()).toMatchInlineSnapshot('"<img src=\\"/_ipx/s_800x1200/image.png\\" width=\\"200\\" height=\\"300\\" data-nuxt-img=\\"\\" sizes=\\"(max-width: 300px) 300px, 400px\\" srcset=\\"/_ipx/s_300x450/image.png 300w, /_ipx/s_400x600/image.png 400w, /_ipx/s_600x900/image.png 600w, /_ipx/s_800x1200/image.png 800w\\">"')
+  })
+
+  it('empty string densities (fallback to global)', () => {
+    const img = mountImage({
+      width: 200,
+      height: 300,
+      sizes: '300:300px,400:400px',
+      densities: ' ',
+      src: 'image.png'
+    })
+    expect(img.html()).toMatchInlineSnapshot('"<img src=\\"/_ipx/s_800x1200/image.png\\" width=\\"200\\" height=\\"300\\" data-nuxt-img=\\"\\" sizes=\\"(max-width: 300px) 300px, 400px\\" srcset=\\"/_ipx/s_300x450/image.png 300w, /_ipx/s_400x600/image.png 400w, /_ipx/s_600x900/image.png 600w, /_ipx/s_800x1200/image.png 800w\\">"')
+  })
+
+  it('error on invalid densities', () => {
+    expect(() => mountImage({
+      width: 200,
+      height: 300,
+      densities: 'x',
+      src: 'image.png'
+    })).toThrow(Error)
+  })
+
+  it('with single sizes entry', () => {
+    const img = mountImage({
+      src: '/image.png',
+      width: 300,
+      height: 400,
+      sizes: '150'
+    })
+    expect(img.html()).toMatchInlineSnapshot('"<img src=\\"/_ipx/s_300x400/image.png\\" width=\\"300\\" height=\\"400\\" data-nuxt-img=\\"\\" srcset=\\"/_ipx/s_150x200/image.png 1x, /_ipx/s_300x400/image.png 2x\\">"')
+  })
+
+  it('with single sizes entry (responsive)', () => {
+    const img = mountImage({
+      src: '/image.png',
+      width: 300,
+      height: 400,
+      sizes: 'sm:150'
+    })
+    expect(img.html()).toMatchInlineSnapshot('"<img src=\\"/_ipx/s_300x400/image.png\\" width=\\"300\\" height=\\"400\\" data-nuxt-img=\\"\\" srcset=\\"/_ipx/s_150x200/image.png 1x, /_ipx/s_300x400/image.png 2x\\">"')
+  })
+
   it('de-duplicates sizes & srcset', () => {
     const img = mountImage({
       width: 200,
@@ -69,7 +120,7 @@ describe('Renders simple image', () => {
       sizes: '200,500:500,900:900',
       src: '/汉字.png'
     })
-    expect(img.html()).toMatchInlineSnapshot('"<img src=\\"/_ipx/s_1800x1800/%E6%B1%89%E5%AD%97.png\\" width=\\"200\\" height=\\"200\\" data-nuxt-img=\\"\\" sizes=\\"(max-width: 500px) 500px, 900px\\" srcset=\\"/_ipx/s_500x500/%E6%B1%89%E5%AD%97.png 500w, /_ipx/s_900x900/%E6%B1%89%E5%AD%97.png 900w, /_ipx/s_1000x1000/%E6%B1%89%E5%AD%97.png 1000w, /_ipx/s_1800x1800/%E6%B1%89%E5%AD%97.png 1800w\\">"')
+    expect(img.html()).toMatchInlineSnapshot('"<img src=\\"/_ipx/s_1800x1800/%E6%B1%89%E5%AD%97.png\\" width=\\"200\\" height=\\"200\\" data-nuxt-img=\\"\\" sizes=\\"(max-width: 200px) 200px, (max-width: 500px) 500px, 900px\\" srcset=\\"/_ipx/s_200x200/%E6%B1%89%E5%AD%97.png 200w, /_ipx/s_400x400/%E6%B1%89%E5%AD%97.png 400w, /_ipx/s_500x500/%E6%B1%89%E5%AD%97.png 500w, /_ipx/s_900x900/%E6%B1%89%E5%AD%97.png 900w, /_ipx/s_1000x1000/%E6%B1%89%E5%AD%97.png 1000w, /_ipx/s_1800x1800/%E6%B1%89%E5%AD%97.png 1800w\\">"')
   })
 
   it('correctly sets crop', () => {
@@ -173,14 +224,14 @@ describe('Renders placeholded image', () => {
     let sizes = wrapper.find('img').element.getAttribute('sizes')
 
     expect(sizes).toBe(null)
-    expect(placeholderImage.sizes).toBe('(max-width: 500px) 500px, 900px')
+    expect(placeholderImage.sizes).toBe('(max-width: 200px) 200px, (max-width: 500px) 500px, 900px')
 
     resolveImage()
     await nextTick()
 
     sizes = wrapper.find('img').element.getAttribute('sizes')
 
-    expect(sizes).toBe('(max-width: 500px) 500px, 900px')
+    expect(sizes).toBe('(max-width: 200px) 200px, (max-width: 500px) 500px, 900px')
     expect(wrapper.emitted().load[0]).toStrictEqual([loadEvent])
   })
 })
diff --git a/test/unit/picture.test.ts b/test/unit/picture.test.ts
index aee7ada..f52bf85 100644
--- a/test/unit/picture.test.ts
+++ b/test/unit/picture.test.ts
@@ -46,7 +46,7 @@ describe('Renders simple image', () => {
   it('Matches snapshot', () => {
     expect(wrapper.html()).toMatchInlineSnapshot(`
       "<picture>
-        <source type=\\"image/webp\\" sizes=\\"(max-width: 500px) 500px, 900px\\" srcset=\\"/_ipx/f_webp&s_500x500/image.png 500w, /_ipx/f_webp&s_900x900/image.png 900w, /_ipx/f_webp&s_1000x1000/image.png 1000w, /_ipx/f_webp&s_1800x1800/image.png 1800w\\"><img width=\\"200\\" height=\\"200\\" data-nuxt-pic=\\"\\" src=\\"/_ipx/f_png&s_1800x1800/image.png\\" sizes=\\"(max-width: 500px) 500px, 900px\\" srcset=\\"/_ipx/f_png&s_500x500/image.png 500w, /_ipx/f_png&s_900x900/image.png 900w, /_ipx/f_png&s_1000x1000/image.png 1000w, /_ipx/f_png&s_1800x1800/image.png 1800w\\">
+        <source type=\\"image/webp\\" sizes=\\"(max-width: 200px) 200px, (max-width: 500px) 500px, 900px\\" srcset=\\"/_ipx/f_webp&s_200x200/image.png 200w, /_ipx/f_webp&s_400x400/image.png 400w, /_ipx/f_webp&s_500x500/image.png 500w, /_ipx/f_webp&s_900x900/image.png 900w, /_ipx/f_webp&s_1000x1000/image.png 1000w, /_ipx/f_webp&s_1800x1800/image.png 1800w\\"><img width=\\"200\\" height=\\"200\\" data-nuxt-pic=\\"\\" src=\\"/_ipx/f_png&s_1800x1800/image.png\\" sizes=\\"(max-width: 200px) 200px, (max-width: 500px) 500px, 900px\\" srcset=\\"/_ipx/f_png&s_200x200/image.png 200w, /_ipx/f_png&s_400x400/image.png 400w, /_ipx/f_png&s_500x500/image.png 500w, /_ipx/f_png&s_900x900/image.png 900w, /_ipx/f_png&s_1000x1000/image.png 1000w, /_ipx/f_png&s_1800x1800/image.png 1800w\\">
       </picture>"
     `)
   })
@@ -119,7 +119,7 @@ describe('Renders simple image', () => {
 
   it('sizes', () => {
     const sizes = wrapper.find('source').element.getAttribute('sizes')
-    expect(sizes).toBe('(max-width: 500px) 500px, 900px')
+    expect(sizes).toBe('(max-width: 200px) 200px, (max-width: 500px) 500px, 900px')
   })
 
   it('renders src when svg is passed', () => {
@@ -143,7 +143,7 @@ describe('Renders simple image', () => {
     })
     expect(img.html()).toMatchInlineSnapshot(`
       "<picture>
-        <source type=\\"image/webp\\" sizes=\\"(max-width: 500px) 500px, 900px\\" srcset=\\"/_ipx/f_webp&s_500x500/%E6%B1%89%E5%AD%97.png 500w, /_ipx/f_webp&s_900x900/%E6%B1%89%E5%AD%97.png 900w, /_ipx/f_webp&s_1000x1000/%E6%B1%89%E5%AD%97.png 1000w, /_ipx/f_webp&s_1800x1800/%E6%B1%89%E5%AD%97.png 1800w\\"><img width=\\"200\\" height=\\"200\\" data-nuxt-pic=\\"\\" src=\\"/_ipx/f_png&s_1800x1800/%E6%B1%89%E5%AD%97.png\\" sizes=\\"(max-width: 500px) 500px, 900px\\" srcset=\\"/_ipx/f_png&s_500x500/%E6%B1%89%E5%AD%97.png 500w, /_ipx/f_png&s_900x900/%E6%B1%89%E5%AD%97.png 900w, /_ipx/f_png&s_1000x1000/%E6%B1%89%E5%AD%97.png 1000w, /_ipx/f_png&s_1800x1800/%E6%B1%89%E5%AD%97.png 1800w\\">
+        <source type=\\"image/webp\\" sizes=\\"(max-width: 200px) 200px, (max-width: 500px) 500px, 900px\\" srcset=\\"/_ipx/f_webp&s_200x200/%E6%B1%89%E5%AD%97.png 200w, /_ipx/f_webp&s_400x400/%E6%B1%89%E5%AD%97.png 400w, /_ipx/f_webp&s_500x500/%E6%B1%89%E5%AD%97.png 500w, /_ipx/f_webp&s_900x900/%E6%B1%89%E5%AD%97.png 900w, /_ipx/f_webp&s_1000x1000/%E6%B1%89%E5%AD%97.png 1000w, /_ipx/f_webp&s_1800x1800/%E6%B1%89%E5%AD%97.png 1800w\\"><img width=\\"200\\" height=\\"200\\" data-nuxt-pic=\\"\\" src=\\"/_ipx/f_png&s_1800x1800/%E6%B1%89%E5%AD%97.png\\" sizes=\\"(max-width: 200px) 200px, (max-width: 500px) 500px, 900px\\" srcset=\\"/_ipx/f_png&s_200x200/%E6%B1%89%E5%AD%97.png 200w, /_ipx/f_png&s_400x400/%E6%B1%89%E5%AD%97.png 400w, /_ipx/f_png&s_500x500/%E6%B1%89%E5%AD%97.png 500w, /_ipx/f_png&s_900x900/%E6%B1%89%E5%AD%97.png 900w, /_ipx/f_png&s_1000x1000/%E6%B1%89%E5%AD%97.png 1000w, /_ipx/f_png&s_1800x1800/%E6%B1%89%E5%AD%97.png 1800w\\">
       </picture>"
     `)
   })