1- import { Mock , vi , expect } from 'vitest' ;
1+ import { expect } from 'vitest' ;
22import * as React from 'react' ;
33import { Avatar } from '@base-ui/react/avatar' ;
44import { screen , waitFor } from '@mui/internal-test-utils' ;
55import { 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
1045describe ( '<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} ) ;
0 commit comments