diff --git a/src/runtime/components/NuxtImg.vue b/src/runtime/components/NuxtImg.vue index d498adbe2..8b4b77535 100644 --- a/src/runtime/components/NuxtImg.vue +++ b/src/runtime/components/NuxtImg.vue @@ -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) }) } diff --git a/test/nuxt/image.test.ts b/test/nuxt/image.test.ts index 60446a74a..0e65304f1 100644 --- a/test/nuxt/image.test.ts +++ b/test/nuxt/image.test.ts @@ -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((_, _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 const src = '/image.png' @@ -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', () => { diff --git a/test/unit/bundle.test.ts b/test/unit/bundle.test.ts index fb3db6277..afd9b3f57 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(`"11.4k"`) + expect(roundToKilobytes(withImage.totalBytes - withoutImage.totalBytes)).toMatchInlineSnapshot(`"11.5k"`) }) })