Skip to content
Merged
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
12 changes: 12 additions & 0 deletions app/components/skill-card/_SkillCardIdentity.vue
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,17 @@ const emit = defineEmits<{
avatarError: []
}>()

const avatar = useTemplateRef<HTMLImageElement>('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)
</script>
Expand All @@ -31,6 +42,7 @@ const where = computed(() => view.author ? view.repo : view.source)
<div class="skill-id" :class="[`skill-id--${size}`, wrap && 'skill-id--wrap']">
<img
v-if="view.byline === 'full'"
ref="avatar"
:src="view.avatar(size)"
alt=""
:width="size"
Expand Down
14 changes: 14 additions & 0 deletions layers/marketing/app/components/BoardRankedList.vue
Original file line number Diff line number Diff line change
Expand Up @@ -55,11 +55,25 @@ const chunks = computed<ListChunk[]>(() => [
<ol class="board-list editorial-ledger list-none p-0" :start="chunk.start">
<li v-for="(row, offset) in chunk.rows" :key="row.key">
<TrendingBoardItem
v-if="chunkIndex === 0"
:row="row"
:rank="chunk.start + offset"
:surface="surface"
@avatar-error="owner => emit('avatarError', owner)"
/>
<!--
Rows past the head render on the server with every post, and hydrate
once they scroll into view. Hydrating all thirty rows at once was
the board's longest task.
-->
<LazyTrendingBoardItem
v-else
hydrate-on-visible
:row="row"
:rank="chunk.start + offset"
:surface="surface"
@avatar-error="(owner: string) => emit('avatarError', owner)"
/>
</li>
</ol>
</template>
Expand Down
42 changes: 42 additions & 0 deletions test/unit/skill-card-avatar-error.nuxt.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>)[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()
})
})
Loading