Skip to content

Commit 165daaa

Browse files
committed
fix fallback flash when image is cached
1 parent 66c97c3 commit 165daaa

2 files changed

Lines changed: 123 additions & 11 deletions

File tree

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

Lines changed: 118 additions & 11 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 />', () => {
1146
const { render } = 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,80 @@ 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('shows the image for a pre-loaded src', 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+
await render(
198+
<Avatar.Root>
199+
<Avatar.Image src={DATA_URI} alt="Jane Doe" />
200+
<Avatar.Fallback>JD</Avatar.Fallback>
201+
</Avatar.Root>,
202+
);
203+
204+
expect(screen.getByRole('img')).toHaveAttribute('src', DATA_URI);
205+
expect(screen.queryByText('JD')).toBe(null);
206+
});
207+
208+
it('shows the fallback for an invalid src', async () => {
209+
// Restore real Image so this test exercises actual browser behavior
210+
restoreImage();
211+
212+
await render(
213+
<Avatar.Root>
214+
<Avatar.Image src="data:image/png;base64,invalid" alt="Jane Doe" />
215+
<Avatar.Fallback>JD</Avatar.Fallback>
216+
</Avatar.Root>,
217+
);
218+
219+
await waitFor(() => {
220+
expect(screen.queryByRole('img')).toBe(null);
221+
expect(screen.getByText('JD')).toBeVisible();
222+
});
223+
});
224+
});
225+
226+
it.skipIf(!isJSDOM)('shows the image immediately for a cached src', async () => {
227+
await render(
228+
<Avatar.Root>
229+
<Avatar.Image src="https://example.com/cached-avatar.png" alt="Jane Doe" />
230+
<Avatar.Fallback>JD</Avatar.Fallback>
231+
</Avatar.Root>,
232+
);
233+
234+
expect(screen.getByRole('img')).toHaveAttribute(
235+
'src',
236+
'https://example.com/cached-avatar.png',
237+
);
238+
expect(screen.queryByText('JD')).toBe(null);
239+
});
240+
241+
it.skipIf(!isJSDOM)('shows the fallback for a cached broken image', async () => {
242+
restoreImage();
243+
restoreImage = mockCachedImageLoading({ naturalWidth: 0 });
244+
245+
await render(
246+
<Avatar.Root>
247+
<Avatar.Image src="https://example.com/broken.png" alt="Jane Doe" />
248+
<Avatar.Fallback>JD</Avatar.Fallback>
249+
</Avatar.Root>,
250+
);
251+
252+
expect(screen.queryByRole('img')).toBe(null);
253+
expect(screen.getByText('JD')).toBeVisible();
254+
});
148255
});

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)