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 />' , ( ) => {
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} ) ;
0 commit comments