Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
Next Next commit
fix(avatar): improve image loading status handling and add crossOrigi…
…n prop
  • Loading branch information
sadeghbarati committed Apr 20, 2025
commit 62f772395ca2afbc99eed634bbbca7cd8ab44429
32 changes: 17 additions & 15 deletions packages/core/src/Avatar/AvatarFallback.vue
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,8 @@ export interface AvatarFallbackProps extends PrimitiveProps {
</script>

<script setup lang="ts">
import { ref, watch } from 'vue'
import { Primitive } from '@/Primitive'
import { ref, watch } from 'vue'
import { injectAvatarRootContext } from './AvatarRoot.vue'

const props = withDefaults(defineProps<AvatarFallbackProps>(), {
Expand All @@ -21,22 +21,24 @@ const props = withDefaults(defineProps<AvatarFallbackProps>(), {
const rootContext = injectAvatarRootContext()
useForwardExpose()

const canRender = ref(false)
let timeout: ReturnType<typeof setTimeout> | undefined

watch(rootContext.imageLoadingStatus, (value) => {
if (value === 'loading') {
canRender.value = false
if (props.delayMs) {
timeout = setTimeout(() => {
canRender.value = true
clearTimeout(timeout)
}, props.delayMs)
}
else {
const canRender = ref(props.delayMs === undefined)

watch(rootContext.imageLoadingStatus, (value, _, onCleanup) => {
let timerId: ReturnType<typeof setTimeout> | undefined
if (props.delayMs !== undefined) {
timerId = setTimeout(() => {
canRender.value = true
}
}, props.delayMs)
}
else {
canRender.value = true
}

onCleanup(() => {
if (timerId) {
clearTimeout(timerId)
}
})
}, { immediate: true })
</script>

Expand Down
8 changes: 5 additions & 3 deletions packages/core/src/Avatar/AvatarImage.vue
Original file line number Diff line number Diff line change
Expand Up @@ -13,23 +13,25 @@ export type AvatarImageEmits = {
export interface AvatarImageProps extends PrimitiveProps {
src: string
referrerPolicy?: ImgHTMLAttributes['referrerpolicy']
crossOrigin?: ImgHTMLAttributes['crossorigin']
}
</script>

<script setup lang="ts">
import { type ImgHTMLAttributes, toRefs, watch } from 'vue'
import type { ImgHTMLAttributes } from 'vue'
import { toRefs, watch } from 'vue'
import { Primitive } from '../Primitive'
import { injectAvatarRootContext } from './AvatarRoot.vue'
import { useImageLoadingStatus } from './utils'

const props = withDefaults(defineProps<AvatarImageProps>(), { as: 'img' })
const emits = defineEmits<AvatarImageEmits>()

const { src, referrerPolicy } = toRefs(props)
const { src, referrerPolicy, crossOrigin } = toRefs(props)
useForwardExpose()
const rootContext = injectAvatarRootContext()

const imageLoadingStatus = useImageLoadingStatus(src, referrerPolicy)
const imageLoadingStatus = useImageLoadingStatus(src, { referrerPolicy, crossOrigin })

watch(
imageLoadingStatus,
Expand Down
6 changes: 3 additions & 3 deletions packages/core/src/Avatar/AvatarRoot.vue
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
<script lang="ts">
import type { Ref } from 'vue'
import type { PrimitiveProps } from '@/Primitive'
import type { Ref } from 'vue'
import type { ImageLoadingStatus } from './utils'
import { createContext, useForwardExpose } from '@/shared'

Expand All @@ -15,8 +15,8 @@ export const [injectAvatarRootContext, provideAvatarRootContext]
</script>

<script setup lang="ts">
import { ref } from 'vue'
import { Primitive } from '@/Primitive'
import { ref } from 'vue'

withDefaults(defineProps<AvatarRootProps>(), {
as: 'span',
Expand All @@ -25,7 +25,7 @@ withDefaults(defineProps<AvatarRootProps>(), {
useForwardExpose()

provideAvatarRootContext({
imageLoadingStatus: ref<ImageLoadingStatus>('loading'),
imageLoadingStatus: ref<ImageLoadingStatus>('idle'),
})
</script>

Expand Down
10 changes: 5 additions & 5 deletions packages/core/src/Avatar/index.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,3 @@
export {
injectAvatarRootContext,
default as AvatarRoot,
type AvatarRootProps,
} from './AvatarRoot.vue'
export {
default as AvatarFallback,
type AvatarFallbackProps,
Expand All @@ -12,3 +7,8 @@ export {
type AvatarImageEmits,
type AvatarImageProps,
} from './AvatarImage.vue'
export {
default as AvatarRoot,
type AvatarRootProps,
injectAvatarRootContext,
} from './AvatarRoot.vue'
74 changes: 57 additions & 17 deletions packages/core/src/Avatar/utils.ts
Original file line number Diff line number Diff line change
@@ -1,33 +1,73 @@
import { type ImgHTMLAttributes, type Ref, onMounted, onUnmounted, ref, watch } from 'vue'
import type { ImgHTMLAttributes, Ref } from 'vue'
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'

export type ImageLoadingStatus = 'idle' | 'loading' | 'loaded' | 'error'

export function useImageLoadingStatus(src: Ref<string>, referrerPolicy?: Ref<ImgHTMLAttributes['referrerpolicy']>) {
const loadingStatus = ref<ImageLoadingStatus>('idle')
function resolveLoadingStatus(image: HTMLImageElement | null, src?: string): ImageLoadingStatus {
if (!image) {
return 'idle'
}
if (!src) {
return 'error'
}
if (image.src !== src) {
image.src = src
}
return image.complete && image.naturalWidth > 0 ? 'loaded' : 'loading'
}

export function useImageLoadingStatus(src: Ref<string>, { referrerPolicy, crossOrigin }: { referrerPolicy?: Ref<ImgHTMLAttributes['referrerpolicy']>, crossOrigin?: Ref<ImgHTMLAttributes['crossorigin']> } = {}) {
const isMounted = ref(false)
const imageRef = ref<HTMLImageElement | null>(null)
const image = computed(() => {
if (!isMounted.value) {
return null
}
if (!imageRef.value) {
imageRef.value = new window.Image()
}
return imageRef.value
})

const loadingStatus = ref<ImageLoadingStatus>(resolveLoadingStatus(image.value, src.value))

const updateStatus = (status: ImageLoadingStatus) => () => {
if (isMounted.value)
loadingStatus.value = status
}

onMounted(() => {
watch(
[() => image.value, () => src.value],
([image, src]) => {
loadingStatus.value = resolveLoadingStatus(image, src)
},
{ immediate: true },
)
})

onMounted(() => {
isMounted.value = true

watch([() => src.value, () => referrerPolicy?.value], ([src, referrer]) => {
if (!src) {
loadingStatus.value = 'error'
}
else {
const image = new window.Image()
loadingStatus.value = 'loading'
image.onload = updateStatus('loaded')
image.onerror = updateStatus('error')
image.src = src
if (referrer) {
image.referrerPolicy = referrer
}
}
watch([() => src.value, () => referrerPolicy?.value, () => crossOrigin?.value], ([src, referrerPolicy], _, onCleanup) => {
Copy link
Member

@zernonia zernonia Apr 22, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

onCleanup in watch is 3.5+ feature, I believe this might not a feasible solution for earlier version. Perhaps use watchEffect?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hi Zernonia 👋

Right, will change it

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done

if (!image.value)
return

const handleLoad = updateStatus('loaded')
const handleError = updateStatus('error')

image.value.addEventListener('load', handleLoad)

Check failure on line 59 in packages/core/src/Avatar/utils.ts

View workflow job for this annotation

GitHub Actions / test

src/Avatar/Avatar.test.ts > given an Avatar with fallback and delayed render

TypeError: image.value.addEventListener is not a function ❯ watch.immediate src/Avatar/utils.ts:59:19 ❯ callWithErrorHandling ../../node_modules/.pnpm/@VUE+runtime-core@3.5.13/node_modules/@vue/runtime-core/dist/runtime-core.cjs.js:200:19 ❯ callWithAsyncErrorHandling ../../node_modules/.pnpm/@VUE+runtime-core@3.5.13/node_modules/@vue/runtime-core/dist/runtime-core.cjs.js:207:17 ❯ baseWatchOptions.call ../../node_modules/.pnpm/@VUE+runtime-core@3.5.13/node_modules/@vue/runtime-core/dist/runtime-core.cjs.js:6136:47 ❯ job ../../node_modules/.pnpm/@VUE+reactivity@3.5.13/node_modules/@vue/reactivity/dist/reactivity.cjs.js:1807:18 ❯ Object.watch ../../node_modules/.pnpm/@VUE+reactivity@3.5.13/node_modules/@vue/reactivity/dist/reactivity.cjs.js:1843:7 ❯ doWatch ../../node_modules/.pnpm/@VUE+runtime-core@3.5.13/node_modules/@vue/runtime-core/dist/runtime-core.cjs.js:6164:34 ❯ Module.watch ../../node_modules/.pnpm/@VUE+runtime-core@3.5.13/node_modules/@vue/runtime-core/dist/runtime-core.cjs.js:6097:10 ❯ src/Avatar/utils.ts:52:5 ❯ ../../node_modules/.pnpm/@VUE+runtime-core@3.5.13/node_modules/@vue/runtime-core/dist/runtime-core.cjs.js:2800:40

Check failure on line 59 in packages/core/src/Avatar/utils.ts

View workflow job for this annotation

GitHub Actions / test

src/Avatar/Avatar.test.ts > given an Avatar with fallback and a working image

TypeError: image.value.addEventListener is not a function ❯ watch.immediate src/Avatar/utils.ts:59:19 ❯ callWithErrorHandling ../../node_modules/.pnpm/@VUE+runtime-core@3.5.13/node_modules/@vue/runtime-core/dist/runtime-core.cjs.js:200:19 ❯ callWithAsyncErrorHandling ../../node_modules/.pnpm/@VUE+runtime-core@3.5.13/node_modules/@vue/runtime-core/dist/runtime-core.cjs.js:207:17 ❯ baseWatchOptions.call ../../node_modules/.pnpm/@VUE+runtime-core@3.5.13/node_modules/@vue/runtime-core/dist/runtime-core.cjs.js:6136:47 ❯ job ../../node_modules/.pnpm/@VUE+reactivity@3.5.13/node_modules/@vue/reactivity/dist/reactivity.cjs.js:1807:18 ❯ Object.watch ../../node_modules/.pnpm/@VUE+reactivity@3.5.13/node_modules/@vue/reactivity/dist/reactivity.cjs.js:1843:7 ❯ doWatch ../../node_modules/.pnpm/@VUE+runtime-core@3.5.13/node_modules/@vue/runtime-core/dist/runtime-core.cjs.js:6164:34 ❯ Module.watch ../../node_modules/.pnpm/@VUE+runtime-core@3.5.13/node_modules/@vue/runtime-core/dist/runtime-core.cjs.js:6097:10 ❯ src/Avatar/utils.ts:52:5 ❯ ../../node_modules/.pnpm/@VUE+runtime-core@3.5.13/node_modules/@vue/runtime-core/dist/runtime-core.cjs.js:2800:40
image.value.addEventListener('error', handleError)

if (referrerPolicy)
image.value.referrerPolicy = referrerPolicy
if (typeof crossOrigin === 'string')
image.value.crossOrigin = crossOrigin

onCleanup(() => {
image.value?.removeEventListener('load', handleLoad)
image.value?.removeEventListener('error', handleError)
})
}, { immediate: true })
})

Expand Down
Loading