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;