Repository: nuxt/image
Pull request #1338 (2024-04-23 11:26:00)

fix: move src attribute to end of <img> tag to fix lazy loading (#1317)
As described in this Vue core issue (https://github.com/vuejs/core/issues/10764), using the `src` attribute before `loading` results in the lazy-loading being inconsistent across loads. This PR moves the `src` tag to the last position in the element.

I tested the behavior before and after the change in the playground to confirm it is working correctly. If you want I can also commit the playground page for reference.

Feel free to correct me on anything I might have done wrong, be it in the code or while making this PR. I'm not very experienced with the process of contributing to public repos yet!


Patch:
diff --git a/src/runtime/components/nuxt-img.ts b/src/runtime/components/nuxt-img.ts
index 37388aa..0df2f93 100644
--- a/src/runtime/components/nuxt-img.ts
+++ b/src/runtime/components/nuxt-img.ts
@@ -147,11 +147,11 @@ export default defineComponent({
 
     return () => h('img', {
       ref: imgEl,
-      src: src.value,
       ...import.meta.server ? { onerror: 'this.setAttribute(\'data-error\', 1)' } : {},
       ...attrs.value,
       ...ctx.attrs,
       class: props.placeholder && !placeholderLoaded.value ? [props.placeholderClass] : undefined,
+      src: src.value,
     })
   },
 })


Test patch:
diff --git a/test/unit/image.test.ts b/test/unit/image.test.ts
index b51016a..bbc29c3 100644
--- a/test/unit/image.test.ts
+++ b/test/unit/image.test.ts
@@ -22,7 +22,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) 200px, (max-width: 900px) 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">"')
+    expect(wrapper.html()).toMatchInlineSnapshot(`"<img width="200" height="200" data-nuxt-img="" sizes="(max-width: 500px) 200px, (max-width: 900px) 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" src="/_ipx/s_1800x1800/image.png">"`)
   })
 
   it('props.src is picked up by getImage()', () => {
@@ -52,7 +52,7 @@ describe('Renders simple image', () => {
       densities: '1x 2x 3x',
       src: 'image.png',
     })
-    expect(img.html()).toMatchInlineSnapshot('"<img src="/_ipx/s_1200x1800/image.png" width="200" height="300" data-nuxt-img="" sizes="(max-width: 400px) 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">"')
+    expect(img.html()).toMatchInlineSnapshot(`"<img width="200" height="300" data-nuxt-img="" sizes="(max-width: 400px) 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" src="/_ipx/s_1200x1800/image.png">"`)
   })
 
   it('empty densities (fallback to global)', () => {
@@ -63,7 +63,7 @@ describe('Renders simple image', () => {
       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: 400px) 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">"')
+    expect(img.html()).toMatchInlineSnapshot(`"<img width="200" height="300" data-nuxt-img="" sizes="(max-width: 400px) 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" src="/_ipx/s_800x1200/image.png">"`)
   })
 
   it('empty string densities (fallback to global)', () => {
@@ -74,7 +74,7 @@ describe('Renders simple image', () => {
       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: 400px) 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">"')
+    expect(img.html()).toMatchInlineSnapshot(`"<img width="200" height="300" data-nuxt-img="" sizes="(max-width: 400px) 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" src="/_ipx/s_800x1200/image.png">"`)
   })
 
   it('error on invalid densities', () => {
@@ -93,7 +93,7 @@ describe('Renders simple image', () => {
       height: 400,
       sizes: '150',
     })
-    expect(img.html()).toMatchInlineSnapshot('"<img src="/_ipx/s_300x400/image.png" width="300" height="400" data-nuxt-img="" sizes="150px" srcset="/_ipx/s_150x200/image.png 150w, /_ipx/s_300x400/image.png 300w">"')
+    expect(img.html()).toMatchInlineSnapshot(`"<img width="300" height="400" data-nuxt-img="" sizes="150px" srcset="/_ipx/s_150x200/image.png 150w, /_ipx/s_300x400/image.png 300w" src="/_ipx/s_300x400/image.png">"`)
   })
 
   it('with single sizes entry (responsive)', () => {
@@ -103,7 +103,7 @@ describe('Renders simple image', () => {
       height: 400,
       sizes: 'sm:150',
     })
-    expect(img.html()).toMatchInlineSnapshot('"<img src="/_ipx/s_300x400/image.png" width="300" height="400" data-nuxt-img="" sizes="150px" srcset="/_ipx/s_150x200/image.png 150w, /_ipx/s_300x400/image.png 300w">"')
+    expect(img.html()).toMatchInlineSnapshot(`"<img width="300" height="400" data-nuxt-img="" sizes="150px" srcset="/_ipx/s_150x200/image.png 150w, /_ipx/s_300x400/image.png 300w" src="/_ipx/s_300x400/image.png">"`)
   })
 
   it('de-duplicates sizes & srcset', () => {
@@ -113,7 +113,7 @@ describe('Renders simple image', () => {
       sizes: '200:200px,300:200px,400:400px,400:400px,500:500px,800:800px',
       src: 'image.png',
     })
-    expect(img.html()).toMatchInlineSnapshot('"<img src="/_ipx/s_1600x2400/image.png" width="200" height="300" data-nuxt-img="" sizes="(max-width: 300px) 200px, (max-width: 400px) 200px, (max-width: 500px) 400px, (max-width: 800px) 500px, 800px" srcset="/_ipx/s_200x300/image.png 200w, /_ipx/s_400x600/image.png 400w, /_ipx/s_500x750/image.png 500w, /_ipx/s_800x1200/image.png 800w, /_ipx/s_1000x1500/image.png 1000w, /_ipx/s_1600x2400/image.png 1600w">"')
+    expect(img.html()).toMatchInlineSnapshot(`"<img width="200" height="300" data-nuxt-img="" sizes="(max-width: 300px) 200px, (max-width: 400px) 200px, (max-width: 500px) 400px, (max-width: 800px) 500px, 800px" srcset="/_ipx/s_200x300/image.png 200w, /_ipx/s_400x600/image.png 400w, /_ipx/s_500x750/image.png 500w, /_ipx/s_800x1200/image.png 800w, /_ipx/s_1000x1500/image.png 1000w, /_ipx/s_1600x2400/image.png 1600w" src="/_ipx/s_1600x2400/image.png">"`)
   })
 
   it('encodes characters', () => {
@@ -123,7 +123,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) 200px, (max-width: 900px) 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">"')
+    expect(img.html()).toMatchInlineSnapshot(`"<img width="200" height="200" data-nuxt-img="" sizes="(max-width: 500px) 200px, (max-width: 900px) 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" src="/_ipx/s_1800x1800/%E6%B1%89%E5%AD%97.png">"`)
   })
 
   it('correctly sets crop', () => {
@@ -133,7 +133,7 @@ describe('Renders simple image', () => {
       height: 2000,
       sizes: 'xs:100vw sm:100vw md:300px lg:350px xl:350px 2xl:350px',
     })
-    expect(img.html()).toMatchInlineSnapshot('"<img src="/_ipx/s_1280x2560/image.png" width="1000" height="2000" data-nuxt-img="" sizes="(max-width: 640px) 100vw, (max-width: 768px) 100vw, (max-width: 1024px) 300px, (max-width: 1280px) 350px, (max-width: 1536px) 350px, 350px" srcset="/_ipx/s_300x600/image.png 300w, /_ipx/s_320x640/image.png 320w, /_ipx/s_350x700/image.png 350w, /_ipx/s_600x1200/image.png 600w, /_ipx/s_640x1280/image.png 640w, /_ipx/s_700x1400/image.png 700w, /_ipx/s_1280x2560/image.png 1280w">"')
+    expect(img.html()).toMatchInlineSnapshot(`"<img width="1000" height="2000" data-nuxt-img="" sizes="(max-width: 640px) 100vw, (max-width: 768px) 100vw, (max-width: 1024px) 300px, (max-width: 1280px) 350px, (max-width: 1536px) 350px, 350px" srcset="/_ipx/s_300x600/image.png 300w, /_ipx/s_320x640/image.png 320w, /_ipx/s_350x700/image.png 350w, /_ipx/s_600x1200/image.png 600w, /_ipx/s_640x1280/image.png 640w, /_ipx/s_700x1400/image.png 700w, /_ipx/s_1280x2560/image.png 1280w" src="/_ipx/s_1280x2560/image.png">"`)
   })
 
   it('without sizes, but densities', () => {
@@ -143,7 +143,7 @@ describe('Renders simple image', () => {
       height: 400,
       densities: '1x 2x 3x',
     })
-    expect(img.html()).toMatchInlineSnapshot('"<img src="/_ipx/s_300x400/image.png" width="300" height="400" data-nuxt-img="" srcset="/_ipx/s_300x400/image.png 1x, /_ipx/s_600x800/image.png 2x, /_ipx/s_900x1200/image.png 3x">"')
+    expect(img.html()).toMatchInlineSnapshot(`"<img width="300" height="400" data-nuxt-img="" srcset="/_ipx/s_300x400/image.png 1x, /_ipx/s_600x800/image.png 2x, /_ipx/s_900x1200/image.png 3x" src="/_ipx/s_300x400/image.png">"`)
   })
 
   it('with nonce', () => {
@@ -307,7 +307,7 @@ describe('Renders image, applies module config', () => {
         sizes: '200,500:500,900:900',
       },
     })
-    expect(img.html()).toMatchInlineSnapshot('"<img src="/_ipx/q_75&s_1800x1800/image.png" width="200" height="200" data-nuxt-img="" sizes="(max-width: 500px) 200px, (max-width: 900px) 500px, 900px" srcset="/_ipx/q_75&s_200x200/image.png 200w, /_ipx/q_75&s_400x400/image.png 400w, /_ipx/q_75&s_500x500/image.png 500w, /_ipx/q_75&s_900x900/image.png 900w, /_ipx/q_75&s_1000x1000/image.png 1000w, /_ipx/q_75&s_1800x1800/image.png 1800w">"')
+    expect(img.html()).toMatchInlineSnapshot(`"<img width="200" height="200" data-nuxt-img="" sizes="(max-width: 500px) 200px, (max-width: 900px) 500px, 900px" srcset="/_ipx/q_75&s_200x200/image.png 200w, /_ipx/q_75&s_400x400/image.png 400w, /_ipx/q_75&s_500x500/image.png 500w, /_ipx/q_75&s_900x900/image.png 900w, /_ipx/q_75&s_1000x1000/image.png 1000w, /_ipx/q_75&s_1800x1800/image.png 1800w" src="/_ipx/q_75&s_1800x1800/image.png">"`)
   })
 
   it('Module config .quality + props.quality => props.quality applies', () => {
@@ -327,7 +327,7 @@ describe('Renders image, applies module config', () => {
         quality: 90,
       },
     })
-    expect(img.html()).toMatchInlineSnapshot('"<img src="/_ipx/q_90&s_1800x1800/image.png" width="200" height="200" data-nuxt-img="" sizes="(max-width: 500px) 200px, (max-width: 900px) 500px, 900px" srcset="/_ipx/q_90&s_200x200/image.png 200w, /_ipx/q_90&s_400x400/image.png 400w, /_ipx/q_90&s_500x500/image.png 500w, /_ipx/q_90&s_900x900/image.png 900w, /_ipx/q_90&s_1000x1000/image.png 1000w, /_ipx/q_90&s_1800x1800/image.png 1800w">"')
+    expect(img.html()).toMatchInlineSnapshot(`"<img width="200" height="200" data-nuxt-img="" sizes="(max-width: 500px) 200px, (max-width: 900px) 500px, 900px" srcset="/_ipx/q_90&s_200x200/image.png 200w, /_ipx/q_90&s_400x400/image.png 400w, /_ipx/q_90&s_500x500/image.png 500w, /_ipx/q_90&s_900x900/image.png 900w, /_ipx/q_90&s_1000x1000/image.png 1000w, /_ipx/q_90&s_1800x1800/image.png 1800w" src="/_ipx/q_90&s_1800x1800/image.png">"`)
   })
 
   it('Without quality config => default image', () => {
@@ -345,7 +345,7 @@ describe('Renders image, applies module config', () => {
         sizes: '200,500:500,900:900',
       },
     })
-    expect(img.html()).toMatchInlineSnapshot('"<img src="/_ipx/s_1800x1800/image.png" width="200" height="200" data-nuxt-img="" sizes="(max-width: 500px) 200px, (max-width: 900px) 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">"')
+    expect(img.html()).toMatchInlineSnapshot(`"<img width="200" height="200" data-nuxt-img="" sizes="(max-width: 500px) 200px, (max-width: 900px) 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" src="/_ipx/s_1800x1800/image.png">"`)
   })
 })