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 + ); }