diff --git a/.yarn/versions/4905f29b.yml b/.yarn/versions/4905f29b.yml new file mode 100644 index 000000000..50d060d51 --- /dev/null +++ b/.yarn/versions/4905f29b.yml @@ -0,0 +1,5 @@ +releases: + "@radix-ui/react-avatar": patch + +declined: + - primitives diff --git a/packages/react/avatar/src/Avatar.stories.tsx b/packages/react/avatar/src/Avatar.stories.tsx index 0ee66a2bf..28a1f70dd 100644 --- a/packages/react/avatar/src/Avatar.stories.tsx +++ b/packages/react/avatar/src/Avatar.stories.tsx @@ -1,9 +1,11 @@ import { css } from '../../../../stitches.config'; import * as Avatar from '@radix-ui/react-avatar'; +import React from 'react'; export default { title: 'Components/Avatar' }; const src = 'https://picsum.photos/id/1005/400/400'; +const srcAlternative = 'https://picsum.photos/id/1006/400/400'; const srcBroken = 'https://broken.link.com/broken-pic.jpg'; export const Styled = () => ( @@ -33,6 +35,18 @@ export const Styled = () => ( + +

Changing image src

+ + {(src) => ( + + + + JS + + + )} + ); @@ -58,6 +72,18 @@ export const Chromatic = () => ( + +

Changing image src

+ + {(src) => ( + + + + JS + + + )} + ); Chromatic.parameters = { chromatic: { disable: false, delay: 1000 } }; @@ -113,3 +139,21 @@ const AvatarIcon = () => ( /> ); + +function SourceChanger({ + sources, + children, +}: { + sources: string[]; + children: (src: string) => React.ReactElement; +}) { + const [src, setSrc] = React.useState(sources[0]); + React.useEffect(() => { + const interval = setInterval(() => { + const nextIndex = (sources.indexOf(src) + 1) % sources.length; + setSrc(sources[nextIndex]); + }, 1000); + return () => clearInterval(interval); + }, [sources, src]); + return children(src); +} diff --git a/packages/react/avatar/src/Avatar.test.tsx b/packages/react/avatar/src/Avatar.test.tsx index e1705c6f3..302fe7314 100644 --- a/packages/react/avatar/src/Avatar.test.tsx +++ b/packages/react/avatar/src/Avatar.test.tsx @@ -1,5 +1,5 @@ import { axe } from 'jest-axe'; -import type { RenderResult } from '@testing-library/react'; +import { RenderResult } from '@testing-library/react'; import { render } from '@testing-library/react'; import * as Avatar from '@radix-ui/react-avatar'; @@ -7,6 +7,39 @@ const ROOT_TEST_ID = 'avatar-root'; const FALLBACK_TEXT = 'AB'; const IMAGE_ALT_TEXT = 'Fake Avatar'; const DELAY = 300; +const cache = new Set(); + +class MockImage extends EventTarget { + _src: string = ''; + + constructor() { + super(); + return this; + } + + get src() { + return this._src; + } + + set src(src: string) { + if (!src) { + return; + } + this._src = src; + setTimeout(() => { + this.dispatchEvent(new Event('load')); + cache.add(this.src); + }, DELAY); + } + + get complete() { + return !this.src || cache.has(this.src); + } + + get naturalWidth() { + return this.complete ? 300 : 0; + } +} describe('given an Avatar with fallback and no image', () => { let rendered: RenderResult; @@ -27,32 +60,26 @@ describe('given an Avatar with fallback and no image', () => { describe('given an Avatar with fallback and a working image', () => { let rendered: RenderResult; let image: HTMLElement | null = null; - const orignalGlobalImage = window.Image; + const originalGlobalImage = window.Image; + const ui = (src?: string) => ( + + {FALLBACK_TEXT} + + + ); beforeAll(() => { - (window.Image as any) = class MockImage { - onload: () => void = () => {}; - src: string = ''; - constructor() { - setTimeout(() => { - this.onload(); - }, DELAY); - return this; - } - }; + (window.Image as any) = MockImage; }); afterAll(() => { - window.Image = orignalGlobalImage; + window.Image = originalGlobalImage; + jest.restoreAllMocks(); }); beforeEach(() => { - rendered = render( - - {FALLBACK_TEXT} - - - ); + cache.clear(); + rendered = render(ui('/test.png')); }); it('should render the fallback initially', () => { @@ -74,6 +101,65 @@ describe('given an Avatar with fallback and a working image', () => { image = await rendered.findByAltText(IMAGE_ALT_TEXT); expect(image).toBeInTheDocument(); }); + + it('does not leak event listeners', async () => { + rendered.unmount(); + const addEventListenerSpy = jest.spyOn(window.Image.prototype, 'addEventListener'); + const removeEventListenerSpy = jest.spyOn(window.Image.prototype, 'removeEventListener'); + rendered = render(ui('/test.png')); + rendered.unmount(); + expect(addEventListenerSpy.mock.calls.length).toEqual(removeEventListenerSpy.mock.calls.length); + }); + + it('can handle changing src', async () => { + image = await rendered.findByRole('img'); + expect(image).toBeInTheDocument(); + rendered.rerender(ui('/test2.png')); + image = rendered.queryByRole('img'); + expect(image).not.toBeInTheDocument(); + image = await rendered.findByRole('img'); + expect(image).toBeInTheDocument(); + }); + + it('should render the image immediately after it is cached', async () => { + image = await rendered.findByRole('img'); + expect(image).toBeInTheDocument(); + + rendered.unmount(); + rendered = render(ui('/test.png')); + image = rendered.queryByRole('img'); + expect(image).toBeInTheDocument(); + }); + + it('should not render image with no src', async () => { + rendered.rerender(ui()); + image = rendered.queryByRole('img'); + expect(image).not.toBeInTheDocument(); + rendered.unmount(); + rendered = render(ui()); + image = rendered.queryByRole('img'); + expect(image).not.toBeInTheDocument(); + }); + + it('should not render image with empty string as src', async () => { + rendered.rerender(ui('')); + image = rendered.queryByRole('img'); + expect(image).not.toBeInTheDocument(); + rendered.unmount(); + rendered = render(ui('')); + image = rendered.queryByRole('img'); + expect(image).not.toBeInTheDocument(); + }); + + it('should show fallback if image has no data', async () => { + rendered.unmount(); + const spy = jest.spyOn(window.Image.prototype, 'naturalWidth', 'get'); + spy.mockReturnValue(0); + rendered = render(ui('/test.png')); + const fallback = rendered.queryByText(FALLBACK_TEXT); + expect(fallback).toBeInTheDocument(); + spy.mockRestore(); + }); }); describe('given an Avatar with fallback and delayed render', () => { diff --git a/packages/react/avatar/src/Avatar.tsx b/packages/react/avatar/src/Avatar.tsx index 3749fc218..c901c9cee 100644 --- a/packages/react/avatar/src/Avatar.tsx +++ b/packages/react/avatar/src/Avatar.tsx @@ -26,6 +26,7 @@ const [AvatarProvider, useAvatarContext] = createAvatarContext; type PrimitiveSpanProps = React.ComponentPropsWithoutRef; + interface AvatarProps extends PrimitiveSpanProps {} const Avatar = React.forwardRef( @@ -54,6 +55,7 @@ const IMAGE_NAME = 'AvatarImage'; type AvatarImageElement = React.ElementRef; type PrimitiveImageProps = React.ComponentPropsWithoutRef; + interface AvatarImageProps extends PrimitiveImageProps { onLoadingStatusChange?: (status: ImageLoadingStatus) => void; } @@ -89,6 +91,7 @@ AvatarImage.displayName = IMAGE_NAME; const FALLBACK_NAME = 'AvatarFallback'; type AvatarFallbackElement = React.ElementRef; + interface AvatarFallbackProps extends PrimitiveSpanProps { delayMs?: number; } @@ -116,35 +119,47 @@ AvatarFallback.displayName = FALLBACK_NAME; /* -----------------------------------------------------------------------------------------------*/ +function setImageSrcAndGetInitialState(image: HTMLImageElement, src?: string): ImageLoadingStatus { + if (!src) { + return 'error'; + } + if (image.src !== src) { + image.src = src; + } + return image.complete && image.naturalWidth > 0 ? 'loaded' : 'loading'; +} + function useImageLoadingStatus(src?: string) { - const [loadingStatus, setLoadingStatus] = React.useState('idle'); + const image = React.useRef(new window.Image()); + const [loadingStatus, setLoadingStatus] = React.useState(() => + setImageSrcAndGetInitialState(image.current, src) + ); useLayoutEffect(() => { - if (!src) { - setLoadingStatus('error'); - return; - } - - let isMounted = true; - const image = new window.Image(); + setLoadingStatus(setImageSrcAndGetInitialState(image.current, src)); + }, [src]); + useLayoutEffect(() => { const updateStatus = (status: ImageLoadingStatus) => () => { - if (!isMounted) return; setLoadingStatus(status); }; - setLoadingStatus('loading'); - image.onload = updateStatus('loaded'); - image.onerror = updateStatus('error'); - image.src = src; + const img = image.current; + + const handleLoad = updateStatus('loaded'); + const handleError = updateStatus('error'); + img.addEventListener('load', handleLoad); + img.addEventListener('error', handleError); return () => { - isMounted = false; + img.removeEventListener('load', handleLoad); + img.removeEventListener('error', handleError); }; - }, [src]); + }, []); return loadingStatus; } + const Root = Avatar; const Image = AvatarImage; const Fallback = AvatarFallback;