Skip to content

Commit

Permalink
[Avatar] fix flashing when image is already cached
Browse files Browse the repository at this point in the history
  • Loading branch information
rkkautsar committed Jul 9, 2024
1 parent 8175208 commit a11a148
Show file tree
Hide file tree
Showing 4 changed files with 184 additions and 34 deletions.
5 changes: 5 additions & 0 deletions .yarn/versions/4905f29b.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
releases:
"@radix-ui/react-avatar": patch

declined:
- primitives
44 changes: 44 additions & 0 deletions packages/react/avatar/src/Avatar.stories.tsx
Original file line number Diff line number Diff line change
@@ -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 = () => (
Expand Down Expand Up @@ -33,6 +35,18 @@ export const Styled = () => (
<AvatarIcon />
</Avatar.Fallback>
</Avatar.Root>

<h1>Changing image src</h1>
<SourceChanger sources={[src, srcAlternative, srcBroken]}>
{(src) => (
<Avatar.Root className={rootClass()}>
<Avatar.Image className={imageClass()} alt="John Smith" src={src} />
<Avatar.Fallback delayMs={300} className={fallbackClass()}>
JS
</Avatar.Fallback>
</Avatar.Root>
)}
</SourceChanger>
</>
);

Expand All @@ -58,6 +72,18 @@ export const Chromatic = () => (
<AvatarIcon />
</Avatar.Fallback>
</Avatar.Root>

<h1>Changing image src</h1>
<SourceChanger sources={[src, srcAlternative, srcBroken]}>
{(src) => (
<Avatar.Root className={rootClass()}>
<Avatar.Image className={imageClass()} alt="John Smith" src={src} />
<Avatar.Fallback delayMs={300} className={fallbackClass()}>
JS
</Avatar.Fallback>
</Avatar.Root>
)}
</SourceChanger>
</>
);
Chromatic.parameters = { chromatic: { disable: false, delay: 1000 } };
Expand Down Expand Up @@ -113,3 +139,21 @@ const AvatarIcon = () => (
/>
</svg>
);

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);
}
124 changes: 105 additions & 19 deletions packages/react/avatar/src/Avatar.test.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,45 @@
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';

const ROOT_TEST_ID = 'avatar-root';
const FALLBACK_TEXT = 'AB';
const IMAGE_ALT_TEXT = 'Fake Avatar';
const DELAY = 300;
const cache = new Set<string>();

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;
Expand All @@ -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) => (
<Avatar.Root data-testid={ROOT_TEST_ID}>
<Avatar.Fallback>{FALLBACK_TEXT}</Avatar.Fallback>
<Avatar.Image src={src} alt={IMAGE_ALT_TEXT} />
</Avatar.Root>
);

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(
<Avatar.Root data-testid={ROOT_TEST_ID}>
<Avatar.Fallback>{FALLBACK_TEXT}</Avatar.Fallback>
<Avatar.Image src="/test.jpg" alt={IMAGE_ALT_TEXT} />
</Avatar.Root>
);
cache.clear();
rendered = render(ui('/test.png'));
});

it('should render the fallback initially', () => {
Expand All @@ -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', () => {
Expand Down
45 changes: 30 additions & 15 deletions packages/react/avatar/src/Avatar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ const [AvatarProvider, useAvatarContext] = createAvatarContext<AvatarContextValu

type AvatarElement = React.ElementRef<typeof Primitive.span>;
type PrimitiveSpanProps = React.ComponentPropsWithoutRef<typeof Primitive.span>;

interface AvatarProps extends PrimitiveSpanProps {}

const Avatar = React.forwardRef<AvatarElement, AvatarProps>(
Expand Down Expand Up @@ -54,6 +55,7 @@ const IMAGE_NAME = 'AvatarImage';

type AvatarImageElement = React.ElementRef<typeof Primitive.img>;
type PrimitiveImageProps = React.ComponentPropsWithoutRef<typeof Primitive.img>;

interface AvatarImageProps extends PrimitiveImageProps {
onLoadingStatusChange?: (status: ImageLoadingStatus) => void;
}
Expand Down Expand Up @@ -89,6 +91,7 @@ AvatarImage.displayName = IMAGE_NAME;
const FALLBACK_NAME = 'AvatarFallback';

type AvatarFallbackElement = React.ElementRef<typeof Primitive.span>;

interface AvatarFallbackProps extends PrimitiveSpanProps {
delayMs?: number;
}
Expand Down Expand Up @@ -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<ImageLoadingStatus>('idle');
const image = React.useRef(new window.Image());
const [loadingStatus, setLoadingStatus] = React.useState<ImageLoadingStatus>(() =>
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;
Expand Down

0 comments on commit a11a148

Please sign in to comment.