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
9 changes: 9 additions & 0 deletions src/runtime/components/NuxtImg.vue
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,15 @@ onMounted(() => {
emit('load', new Event('load'))
})
.catch((error) => {
// decode() can reject even when the image is renderable - Chromium does this for
// large images - so we gate the error path on whether the image actually failed to load
// see https://issues.chromium.org/issues/40261318
if (img.complete && img.naturalWidth > 0) {
placeholderLoaded.value = true
emit('load', new Event('load'))
return
}

emit('error', error)
})
}
Expand Down
87 changes: 87 additions & 0 deletions test/nuxt/image.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -211,6 +211,49 @@ const getImageLoad = (cb = () => {}) => {
}
}

const getImageDecode = (cb = () => {}) => {
let markLoaded = () => {}
let markFailed = () => {}
let rejectDecode = () => {}
const decodeError = new DOMException('The source image cannot be decoded.', 'EncodingError')
const ImageMock = vi.fn(function (this: HTMLImageElement) {
const _image = {
complete: false,
naturalWidth: 0,
decode: () => new Promise<void>((_, _reject) => {
rejectDecode = () => _reject(decodeError)
}),
}
markLoaded = () => {
_image.complete = true
_image.naturalWidth = 200
}
// a broken image is also `complete`, it just has no intrinsic size
markFailed = () => {
_image.complete = true
}

return _image as unknown as HTMLImageElement
})

vi.stubGlobal('Image', ImageMock)
cb()
vi.unstubAllGlobals()

return {
markLoaded,
markFailed,
rejectDecode,
decodeError,
}
}

// `decode()` settles a promise chain, so its handlers need more than one tick
const flushDecode = async () => {
await nextTick()
await nextTick()
}

describe('Renders placeholder image', () => {
let wrapper: VueWrapper<any>
const src = '/image.png'
Expand Down Expand Up @@ -327,6 +370,50 @@ describe('Renders placeholder image', () => {

expect(wrapper.emitted().error![0]).toStrictEqual([errorEvent])
})

it('props.placeholder swaps when the image loads but `decode` fails', async () => {
const { markLoaded, rejectDecode } = getImageDecode(() => {
wrapper = mount(NuxtImg, {
propsData: {
width: 200,
height: 200,
src,
placeholder: true,
},
})
})

// the image has loaded, only decoding it failed
markLoaded()
rejectDecode()
await flushDecode()

expect(wrapper.find('img').element.getAttribute('src')).toMatchInlineSnapshot('"/_ipx/s_200x200/image.png"')
expect(wrapper.emitted().load).toHaveLength(1)
expect(wrapper.emitted().error).toBeUndefined()
})

it('props.placeholder emits an error when the image itself fails to load', async () => {
const { markFailed, rejectDecode, decodeError } = getImageDecode(() => {
wrapper = mount(NuxtImg, {
propsData: {
width: 200,
height: 200,
src,
placeholder: true,
},
})
})

// a broken image settles as `complete` with no intrinsic size
markFailed()
rejectDecode()
await flushDecode()

expect(wrapper.emitted().error![0]).toStrictEqual([decodeError])
expect(wrapper.emitted().load).toBeUndefined()
expect(wrapper.find('img').element.getAttribute('src')).toMatchInlineSnapshot('"/_ipx/q_50&blur_3&s_10x10/image.png"')
})
})

describe('Sizes and densities behavior', () => {
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(`"11.4k"`)
expect(roundToKilobytes(withImage.totalBytes - withoutImage.totalBytes)).toMatchInlineSnapshot(`"11.5k"`)
})
})

Expand Down
Loading