diff --git a/app/components/skill-card/_SkillCardIdentity.vue b/app/components/skill-card/_SkillCardIdentity.vue index 2d4aa287..8023351f 100644 --- a/app/components/skill-card/_SkillCardIdentity.vue +++ b/app/components/skill-card/_SkillCardIdentity.vue @@ -23,6 +23,17 @@ const emit = defineEmits<{ avatarError: [] }>() +const avatar = useTemplateRef('avatar') + +// An avatar can fail before this card hydrates, such as in a row that +// hydrates only once it scrolls into view. Its error event then fired with no +// listener, so the mount reads the image instead. +onMounted(() => { + const image = avatar.value + if (image?.complete && image.naturalWidth === 0) + emit('avatarError') +}) + /** The author names the owner, so the byline needs only the Repository after it. */ const where = computed(() => view.author ? view.repo : view.source) @@ -31,6 +42,7 @@ const where = computed(() => view.author ? view.repo : view.source)
(() => [
  1. + +
diff --git a/test/unit/skill-card-avatar-error.nuxt.test.ts b/test/unit/skill-card-avatar-error.nuxt.test.ts new file mode 100644 index 00000000..c75ad0e9 --- /dev/null +++ b/test/unit/skill-card-avatar-error.nuxt.test.ts @@ -0,0 +1,42 @@ +import { mountSuspended } from '@nuxt/test-utils/runtime' +import { afterEach, describe, expect, it } from 'vitest' +import SkillCard from '../../app/components/SkillCard.vue' + +const skill = { owner: 'gone-owner', repo: 'skills', name: 'vite', registryPath: '/gh/gone-owner/skills/vite' } + +const prototype = HTMLImageElement.prototype +const original = { + complete: Object.getOwnPropertyDescriptor(prototype, 'complete'), + naturalWidth: Object.getOwnPropertyDescriptor(prototype, 'naturalWidth'), +} + +/** Every image reads as settled, broken or not, as it would after loading before hydration. */ +function settleImages(width: number): void { + Object.defineProperty(prototype, 'complete', { configurable: true, get: () => true }) + Object.defineProperty(prototype, 'naturalWidth', { configurable: true, get: () => width }) +} + +afterEach(() => { + for (const [key, descriptor] of Object.entries(original)) { + if (descriptor) + Object.defineProperty(prototype, key, descriptor) + else + delete (prototype as unknown as Record)[key] + } +}) + +describe('skill card avatar', () => { + it('reports an avatar that failed before the card hydrated', async () => { + settleImages(0) + const wrapper = await mountSuspended(SkillCard, { props: { skill, layout: 'row' } }) + expect(wrapper.emitted('avatarError')).toEqual([['gone-owner']]) + wrapper.unmount() + }) + + it('stays quiet for an avatar that loaded', async () => { + settleImages(36) + const wrapper = await mountSuspended(SkillCard, { props: { skill, layout: 'row' } }) + expect(wrapper.emitted('avatarError')).toBeUndefined() + wrapper.unmount() + }) +})