Skip to content

Commit b3ca1ed

Browse files
authored
[avatar] Fix flash when image is cached (#4469)
1 parent 4733421 commit b3ca1ed

4 files changed

Lines changed: 108 additions & 14 deletions

File tree

docs/src/app/(docs)/react/components/avatar/demos/hero/css-modules/index.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,9 @@ export default function ExampleAvatar() {
1111
height="48"
1212
className={styles.Image}
1313
/>
14-
<Avatar.Fallback className={styles.Fallback}>LT</Avatar.Fallback>
14+
<Avatar.Fallback delay={600} className={styles.Fallback}>
15+
LT
16+
</Avatar.Fallback>
1517
</Avatar.Root>
1618
<Avatar.Root className={styles.Root}>LT</Avatar.Root>
1719
</div>

docs/src/app/(docs)/react/components/avatar/demos/hero/tailwind/index.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,10 @@ export default function ExampleAvatar() {
1010
height="48"
1111
className="size-full object-cover"
1212
/>
13-
<Avatar.Fallback className="flex size-full items-center justify-center text-base">
13+
<Avatar.Fallback
14+
delay={600}
15+
className="flex size-full items-center justify-center text-base"
16+
>
1417
LT
1518
</Avatar.Fallback>
1619
</Avatar.Root>

packages/react/src/avatar/image/AvatarImage.test.tsx

Lines changed: 96 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,61 @@
1-
import { Mock, vi, expect } from 'vitest';
1+
import { expect } from 'vitest';
22
import * as React from 'react';
33
import { Avatar } from '@base-ui/react/avatar';
44
import { screen, waitFor } from '@mui/internal-test-utils';
55
import { describeConformance, createRenderer, isJSDOM } from '#test-utils';
6-
import { useImageLoadingStatus } from './useImageLoadingStatus';
76

8-
vi.mock('./useImageLoadingStatus');
7+
/**
8+
* Replaces `window.Image` with a constructor that simulates a browser's
9+
* cached-image behavior: setting `.src` immediately makes `.complete = true`
10+
* and `.naturalWidth > 0`, but the async `onload` callback has not yet fired
11+
* (it would be queued as a task in a real browser).
12+
*
13+
* This is the exact state the fix targets — without the `image.complete`
14+
* fast-path, the hook would be stuck at `'loading'` until `onload` fires.
15+
*/
16+
function mockCachedImageLoading({ naturalWidth = 100 } = {}) {
17+
const OriginalImage = window.Image;
18+
19+
window.Image = function MockImage() {
20+
let srcValue = '';
21+
const obj = {
22+
complete: false,
23+
naturalWidth: 0,
24+
onload: null as (() => void) | null,
25+
onerror: null as (() => void) | null,
26+
referrerPolicy: '',
27+
crossOrigin: null as string | null,
28+
get src() {
29+
return srcValue;
30+
},
31+
set src(value: string) {
32+
srcValue = value;
33+
obj.complete = true;
34+
obj.naturalWidth = naturalWidth;
35+
},
36+
};
37+
return obj;
38+
} as unknown as typeof window.Image;
39+
40+
return () => {
41+
window.Image = OriginalImage;
42+
};
43+
}
944

1045
describe('<Avatar.Image />', () => {
11-
const { render } = createRenderer();
46+
const { render, renderToString } = createRenderer();
1247

13-
const useImageLoadingStatusMock = useImageLoadingStatus as Mock;
48+
let restoreImage: () => void;
1449

1550
beforeEach(() => {
16-
useImageLoadingStatusMock.mockReturnValue('loaded');
51+
restoreImage = mockCachedImageLoading();
1752
});
1853

1954
afterEach(() => {
20-
vi.clearAllMocks();
55+
restoreImage();
2156
});
2257

23-
describeConformance(<Avatar.Image />, () => ({
58+
describeConformance(<Avatar.Image src="test.png" />, () => ({
2459
render: (node) => {
2560
return render(<Avatar.Root>{node}</Avatar.Root>);
2661
},
@@ -35,8 +70,6 @@ describe('<Avatar.Image />', () => {
3570
it('triggers enter animation via data-starting-style when mounting', async () => {
3671
globalThis.BASE_UI_ANIMATIONS_DISABLED = false;
3772

38-
useImageLoadingStatusMock.mockImplementation((src) => (src ? 'loaded' : 'idle'));
39-
4073
let transitionFinished = false;
4174
function notifyTransitionFinished() {
4275
transitionFinished = true;
@@ -92,8 +125,6 @@ describe('<Avatar.Image />', () => {
92125
it('applies data-ending-style before unmount', async () => {
93126
globalThis.BASE_UI_ANIMATIONS_DISABLED = false;
94127

95-
useImageLoadingStatusMock.mockImplementation((src) => (src ? 'loaded' : 'idle'));
96-
97128
const style = `
98129
@keyframes test-anim {
99130
to {
@@ -145,4 +176,57 @@ describe('<Avatar.Image />', () => {
145176
});
146177
});
147178
});
179+
180+
describe.skipIf(isJSDOM)('cached images', () => {
181+
// 1x1 transparent PNG
182+
const DATA_URI =
183+
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==';
184+
185+
it('does not flash fallback for a cached image during SSR hydration', async () => {
186+
// Restore real Image so this test exercises actual browser caching
187+
restoreImage();
188+
189+
// Pre-load so the browser cache has the decoded image
190+
await new Promise<void>((resolve, reject) => {
191+
const img = new window.Image();
192+
img.onload = () => resolve();
193+
img.onerror = () => reject(new Error('Failed to preload test image'));
194+
img.src = DATA_URI;
195+
});
196+
197+
// Server render: layout effects don't run, so fallback is in the HTML
198+
const { hydrate } = renderToString(
199+
<Avatar.Root>
200+
<Avatar.Image src={DATA_URI} alt="Jane Doe" />
201+
<Avatar.Fallback>JD</Avatar.Fallback>
202+
</Avatar.Root>,
203+
);
204+
205+
expect(screen.getByText('JD')).toBeVisible();
206+
expect(screen.queryByRole('img')).toBe(null);
207+
208+
// After hydration, the layout effect fires synchronously before paint.
209+
// For cached images, image.complete is true so status resolves to 'loaded'
210+
// immediately — no fallback flash.
211+
//
212+
// Assert synchronously (no waitFor) to verify the image is available on
213+
// the first post-hydration render, not after a delayed onload callback.
214+
hydrate();
215+
216+
expect(screen.getByRole('img')).toHaveAttribute('src', DATA_URI);
217+
expect(screen.queryByText('JD')).toBe(null);
218+
});
219+
});
220+
221+
it.skipIf(!isJSDOM)('shows the image immediately for a cached src', async () => {
222+
await render(
223+
<Avatar.Root>
224+
<Avatar.Image src="https://example.com/cached-avatar.png" alt="Jane Doe" />
225+
<Avatar.Fallback>JD</Avatar.Fallback>
226+
</Avatar.Root>,
227+
);
228+
229+
expect(screen.getByRole('img')).toHaveAttribute('src', 'https://example.com/cached-avatar.png');
230+
expect(screen.queryByText('JD')).toBe(null);
231+
});
148232
});

packages/react/src/avatar/image/useImageLoadingStatus.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,11 @@ export function useImageLoadingStatus(
4242
image.crossOrigin = crossOrigin ?? null;
4343
image.src = src;
4444

45+
// Fast path for cached/decoded images
46+
if (image.complete) {
47+
setLoadingStatus(image.naturalWidth > 0 ? 'loaded' : 'error');
48+
}
49+
4550
return () => {
4651
isMounted = false;
4752
};

0 commit comments

Comments
 (0)