Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,9 @@ export default function ExampleAvatar() {
height="48"
className={styles.Image}
/>
<Avatar.Fallback className={styles.Fallback}>LT</Avatar.Fallback>
<Avatar.Fallback delay={600} className={styles.Fallback}>
LT
</Avatar.Fallback>
</Avatar.Root>
<Avatar.Root className={styles.Root}>LT</Avatar.Root>
</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,10 @@ export default function ExampleAvatar() {
height="48"
className="size-full object-cover"
/>
<Avatar.Fallback className="flex size-full items-center justify-center text-base">
<Avatar.Fallback
delay={600}
className="flex size-full items-center justify-center text-base"
>
LT
</Avatar.Fallback>
</Avatar.Root>
Expand Down
108 changes: 96 additions & 12 deletions packages/react/src/avatar/image/AvatarImage.test.tsx
Original file line number Diff line number Diff line change
@@ -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('<Avatar.Image />', () => {
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(<Avatar.Image />, () => ({
describeConformance(<Avatar.Image src="test.png" />, () => ({
render: (node) => {
return render(<Avatar.Root>{node}</Avatar.Root>);
},
Expand All @@ -35,8 +70,6 @@ describe('<Avatar.Image />', () => {
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;
Expand Down Expand Up @@ -92,8 +125,6 @@ describe('<Avatar.Image />', () => {
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 {
Expand Down Expand Up @@ -145,4 +176,57 @@ describe('<Avatar.Image />', () => {
});
});
});

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<void>((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(
<Avatar.Root>
<Avatar.Image src={DATA_URI} alt="Jane Doe" />
<Avatar.Fallback>JD</Avatar.Fallback>
</Avatar.Root>,
);

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(
<Avatar.Root>
<Avatar.Image src="https://example.com/cached-avatar.png" alt="Jane Doe" />
<Avatar.Fallback>JD</Avatar.Fallback>
</Avatar.Root>,
);

expect(screen.getByRole('img')).toHaveAttribute('src', 'https://example.com/cached-avatar.png');
expect(screen.queryByText('JD')).toBe(null);
});
});
5 changes: 5 additions & 0 deletions packages/react/src/avatar/image/useImageLoadingStatus.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
};
Expand Down
Loading