diff --git a/docs/src/app/(docs)/react/components/avatar/demos/hero/css-modules/index.tsx b/docs/src/app/(docs)/react/components/avatar/demos/hero/css-modules/index.tsx index e41a1b249cb..3fccf92002f 100644 --- a/docs/src/app/(docs)/react/components/avatar/demos/hero/css-modules/index.tsx +++ b/docs/src/app/(docs)/react/components/avatar/demos/hero/css-modules/index.tsx @@ -11,7 +11,9 @@ export default function ExampleAvatar() { height="48" className={styles.Image} /> - LT + + LT + LT diff --git a/docs/src/app/(docs)/react/components/avatar/demos/hero/tailwind/index.tsx b/docs/src/app/(docs)/react/components/avatar/demos/hero/tailwind/index.tsx index e3ffe30673e..a88e7763a35 100644 --- a/docs/src/app/(docs)/react/components/avatar/demos/hero/tailwind/index.tsx +++ b/docs/src/app/(docs)/react/components/avatar/demos/hero/tailwind/index.tsx @@ -10,7 +10,10 @@ export default function ExampleAvatar() { height="48" className="size-full object-cover" /> - + LT diff --git a/packages/react/src/avatar/image/AvatarImage.test.tsx b/packages/react/src/avatar/image/AvatarImage.test.tsx index 0d80b2c3675..b128384ca04 100644 --- a/packages/react/src/avatar/image/AvatarImage.test.tsx +++ b/packages/react/src/avatar/image/AvatarImage.test.tsx @@ -1,26 +1,61 @@ -import { Mock, vi, expect } from 'vitest'; +import { expect } from 'vitest'; import * as React from 'react'; import { Avatar } from '@base-ui/react/avatar'; import { screen, waitFor } from '@mui/internal-test-utils'; import { describeConformance, createRenderer, isJSDOM } from '#test-utils'; -import { useImageLoadingStatus } from './useImageLoadingStatus'; -vi.mock('./useImageLoadingStatus'); +/** + * Replaces `window.Image` with a constructor that simulates a browser's + * cached-image behavior: setting `.src` immediately makes `.complete = true` + * and `.naturalWidth > 0`, but the async `onload` callback has not yet fired + * (it would be queued as a task in a real browser). + * + * This is the exact state the fix targets — without the `image.complete` + * fast-path, the hook would be stuck at `'loading'` until `onload` fires. + */ +function mockCachedImageLoading({ naturalWidth = 100 } = {}) { + const OriginalImage = window.Image; + + window.Image = function MockImage() { + let srcValue = ''; + const obj = { + complete: false, + naturalWidth: 0, + onload: null as (() => void) | null, + onerror: null as (() => void) | null, + referrerPolicy: '', + crossOrigin: null as string | null, + get src() { + return srcValue; + }, + set src(value: string) { + srcValue = value; + obj.complete = true; + obj.naturalWidth = naturalWidth; + }, + }; + return obj; + } as unknown as typeof window.Image; + + return () => { + window.Image = OriginalImage; + }; +} describe('', () => { - const { render } = createRenderer(); + const { render, renderToString } = createRenderer(); - const useImageLoadingStatusMock = useImageLoadingStatus as Mock; + let restoreImage: () => void; beforeEach(() => { - useImageLoadingStatusMock.mockReturnValue('loaded'); + restoreImage = mockCachedImageLoading(); }); afterEach(() => { - vi.clearAllMocks(); + restoreImage(); }); - describeConformance(, () => ({ + describeConformance(, () => ({ render: (node) => { return render({node}); }, @@ -35,8 +70,6 @@ describe('', () => { it('triggers enter animation via data-starting-style when mounting', async () => { globalThis.BASE_UI_ANIMATIONS_DISABLED = false; - useImageLoadingStatusMock.mockImplementation((src) => (src ? 'loaded' : 'idle')); - let transitionFinished = false; function notifyTransitionFinished() { transitionFinished = true; @@ -92,8 +125,6 @@ describe('', () => { it('applies data-ending-style before unmount', async () => { globalThis.BASE_UI_ANIMATIONS_DISABLED = false; - useImageLoadingStatusMock.mockImplementation((src) => (src ? 'loaded' : 'idle')); - const style = ` @keyframes test-anim { to { @@ -145,4 +176,57 @@ describe('', () => { }); }); }); + + describe.skipIf(isJSDOM)('cached images', () => { + // 1x1 transparent PNG + const DATA_URI = + 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=='; + + it('does not flash fallback for a cached image during SSR hydration', async () => { + // Restore real Image so this test exercises actual browser caching + restoreImage(); + + // Pre-load so the browser cache has the decoded image + await new Promise((resolve, reject) => { + const img = new window.Image(); + img.onload = () => resolve(); + img.onerror = () => reject(new Error('Failed to preload test image')); + img.src = DATA_URI; + }); + + // Server render: layout effects don't run, so fallback is in the HTML + const { hydrate } = renderToString( + + + JD + , + ); + + expect(screen.getByText('JD')).toBeVisible(); + expect(screen.queryByRole('img')).toBe(null); + + // After hydration, the layout effect fires synchronously before paint. + // For cached images, image.complete is true so status resolves to 'loaded' + // immediately — no fallback flash. + // + // Assert synchronously (no waitFor) to verify the image is available on + // the first post-hydration render, not after a delayed onload callback. + hydrate(); + + expect(screen.getByRole('img')).toHaveAttribute('src', DATA_URI); + expect(screen.queryByText('JD')).toBe(null); + }); + }); + + it.skipIf(!isJSDOM)('shows the image immediately for a cached src', async () => { + await render( + + + JD + , + ); + + expect(screen.getByRole('img')).toHaveAttribute('src', 'https://example.com/cached-avatar.png'); + expect(screen.queryByText('JD')).toBe(null); + }); }); diff --git a/packages/react/src/avatar/image/useImageLoadingStatus.ts b/packages/react/src/avatar/image/useImageLoadingStatus.ts index 07cb0eaa285..407ab48ce5b 100644 --- a/packages/react/src/avatar/image/useImageLoadingStatus.ts +++ b/packages/react/src/avatar/image/useImageLoadingStatus.ts @@ -42,6 +42,11 @@ export function useImageLoadingStatus( image.crossOrigin = crossOrigin ?? null; image.src = src; + // Fast path for cached/decoded images + if (image.complete) { + setLoadingStatus(image.naturalWidth > 0 ? 'loaded' : 'error'); + } + return () => { isMounted = false; };