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 4e71f84ed..640c638a5 100644
--- a/packages/react/avatar/src/Avatar.test.tsx
+++ b/packages/react/avatar/src/Avatar.test.tsx
@@ -2,57 +2,60 @@ import { axe } from 'jest-axe';
import type { RenderResult } from '@testing-library/react';
import { render, waitFor } from '@testing-library/react';
import * as Avatar from '@radix-ui/react-avatar';
+import { HTMLAttributeReferrerPolicy, ReactElement } from 'react';
+import { renderToString } from 'react-dom/server';
const ROOT_TEST_ID = 'avatar-root';
const FALLBACK_TEXT = 'AB';
const IMAGE_ALT_TEXT = 'Fake Avatar';
const DELAY = 300;
+const cache = new Set();
describe('given an Avatar with fallback and no image', () => {
- let rendered: RenderResult;
-
- beforeEach(() => {
- rendered = render(
-
- {FALLBACK_TEXT}
-
- );
- });
+ const ui = (
+
+ {FALLBACK_TEXT}
+
+ );
it('should have no accessibility violations', async () => {
+ const rendered = render(ui);
expect(await axe(rendered.container)).toHaveNoViolations();
});
+
+ it('should work with SSR', () => {
+ const container = document.createElement('div');
+ document.body.appendChild(container);
+ container.innerHTML = renderToString(ui);
+ const rendered = render(ui, { hydrate: true, container });
+ const fallback = rendered.queryByText(FALLBACK_TEXT);
+ expect(fallback).toBeInTheDocument();
+ });
});
-describe('given an Avatar with fallback and a working image', () => {
+describe('given an Avatar with fallback and an 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 +77,91 @@ 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('SSR', () => {
+ function renderAndHydrate(ui: ReactElement) {
+ const container = document.createElement('div');
+ document.body.appendChild(container);
+ container.innerHTML = renderToString(ui);
+ return render(ui, { hydrate: true, container });
+ }
+
+ it('can render with working image', async () => {
+ const rendered = renderAndHydrate(ui('/test.png'));
+ let image = rendered.queryByRole('img');
+ expect(image).not.toBeInTheDocument();
+
+ image = await rendered.findByRole('img');
+ expect(image).toBeInTheDocument();
+ });
+
+ it('can render with no src', () => {
+ const rendered = renderAndHydrate(ui());
+ const image = rendered.queryByRole('img');
+ expect(image).not.toBeInTheDocument();
+ const fallback = rendered.queryByText(FALLBACK_TEXT);
+ expect(fallback).toBeInTheDocument();
+ });
+ });
});
describe('given an Avatar with fallback and delayed render', () => {
@@ -103,39 +191,39 @@ describe('given an Avatar with fallback and delayed render', () => {
describe('given an Avatar with an image that only works when referrerPolicy=no-referrer', () => {
let rendered: RenderResult;
- const orignalGlobalImage = window.Image;
+ const originalGlobalImage = window.Image;
+ const ui = (src?: string, referrerPolicy?: HTMLAttributeReferrerPolicy) => (
+
+ {FALLBACK_TEXT}
+
+
+ );
beforeAll(() => {
- (window.Image as any) = class MockImage {
- onload: () => void = () => {};
- onerror: () => void = () => {};
- src: string = '';
+ (window.Image as any) = class MockNoReferrerImage extends MockImage {
referrerPolicy: string | undefined;
- constructor() {
+
+ onSrcChange() {
setTimeout(() => {
if (this.referrerPolicy === 'no-referrer') {
- this.onload();
+ this.dispatchEvent(new Event('load'));
} else {
- this.onerror();
+ this.dispatchEvent(new Event('error'));
}
}, DELAY);
- return this;
}
};
});
afterAll(() => {
- window.Image = orignalGlobalImage;
+ window.Image = originalGlobalImage;
+ jest.restoreAllMocks();
});
describe('referrerPolicy=no-referrer', () => {
beforeEach(() => {
- rendered = render(
-
- {FALLBACK_TEXT}
-
-
- );
+ cache.clear();
+ rendered = render(ui('/test.png', 'no-referrer'));
});
it('should render the fallback initially', () => {
@@ -161,12 +249,8 @@ describe('given an Avatar with an image that only works when referrerPolicy=no-r
describe('referrerPolicy=origin', () => {
beforeEach(() => {
- rendered = render(
-
- {FALLBACK_TEXT}
-
-
- );
+ cache.clear();
+ rendered = render(ui('/test.png', 'origin'));
});
it('should render the fallback initially', () => {
@@ -187,3 +271,39 @@ describe('given an Avatar with an image that only works when referrerPolicy=no-r
});
});
});
+
+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;
+ this.onSrcChange();
+ }
+
+ get complete() {
+ return !this.src || cache.has(this.src);
+ }
+
+ get naturalWidth() {
+ return this.complete ? 300 : 0;
+ }
+
+ onSrcChange() {
+ setTimeout(() => {
+ this.dispatchEvent(new Event('load'));
+ cache.add(this.src);
+ }, DELAY);
+ }
+}
diff --git a/packages/react/avatar/src/Avatar.tsx b/packages/react/avatar/src/Avatar.tsx
index 53841b7f0..056af2a79 100644
--- a/packages/react/avatar/src/Avatar.tsx
+++ b/packages/react/avatar/src/Avatar.tsx
@@ -116,38 +116,74 @@ AvatarFallback.displayName = FALLBACK_NAME;
/* -----------------------------------------------------------------------------------------------*/
-function useImageLoadingStatus(src?: string, referrerPolicy?: React.HTMLAttributeReferrerPolicy) {
- const [loadingStatus, setLoadingStatus] = React.useState('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';
+}
- useLayoutEffect(() => {
- if (!src) {
- setLoadingStatus('error');
- return;
+function useImageLoadingStatus(src?: string, referrerPolicy?: React.HTMLAttributeReferrerPolicy) {
+ const isHydrated = useIsHydrated()
+ const image = React.useRef(isHydrated ? new window.Image() : null);
+ const img = (() => {
+ if (!isHydrated) return null;
+ if (!image.current) {
+ image.current = new window.Image();
}
+ return image.current;
+ })();
+
+ const [loadingStatus, setLoadingStatus] = React.useState(() =>
+ resolveLoadingStatus(img, src)
+ );
- let isMounted = true;
- const image = new window.Image();
+ useLayoutEffect(() => {
+ setLoadingStatus(resolveLoadingStatus(img, src));
+ }, [img, src]);
+ useLayoutEffect(() => {
const updateStatus = (status: ImageLoadingStatus) => () => {
- if (!isMounted) return;
setLoadingStatus(status);
};
- setLoadingStatus('loading');
- image.onload = updateStatus('loaded');
- image.onerror = updateStatus('error');
- image.src = src;
+ if (!img) return;
+
+ const handleLoad = updateStatus('loaded');
+ const handleError = updateStatus('error');
+ img.addEventListener('load', handleLoad);
+ img.addEventListener('error', handleError);
if (referrerPolicy) {
- image.referrerPolicy = referrerPolicy;
+ img.referrerPolicy = referrerPolicy;
}
return () => {
- isMounted = false;
+ img.removeEventListener('load', handleLoad);
+ img.removeEventListener('error', handleError);
};
- }, [src, referrerPolicy]);
+ }, [img, referrerPolicy]);
return loadingStatus;
}
+
+function subscribe() {
+ return () => {};
+}
+
+function useIsHydrated() {
+ return React.useSyncExternalStore(
+ subscribe,
+ () => true,
+ () => false
+ );
+}
+
const Root = Avatar;
const Image = AvatarImage;
const Fallback = AvatarFallback;
diff --git a/ssr-testing/app/avatar/page.tsx b/ssr-testing/app/avatar/page.tsx
index 599d6e200..b5cff903f 100644
--- a/ssr-testing/app/avatar/page.tsx
+++ b/ssr-testing/app/avatar/page.tsx
@@ -5,6 +5,7 @@ export default function Page() {
return (
A
+
);
}