Skip to content
Open
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
9 changes: 6 additions & 3 deletions src/Image.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -43,8 +43,10 @@ export interface PreviewConfig extends Omit<InternalPreviewConfig, 'countRender'

export type SemanticName = 'root' | 'image' | 'cover';

export interface ImageProps
extends Omit<React.ImgHTMLAttributes<HTMLImageElement>, 'placeholder' | 'onClick' | 'onKeyDown'> {
export interface ImageProps extends Omit<
React.ImgHTMLAttributes<HTMLImageElement>,
'placeholder' | 'onClick' | 'onKeyDown'
> {
// Misc
prefixCls?: string;
previewPrefixCls?: string;
Expand Down Expand Up @@ -159,6 +161,7 @@ const ImageInternal: CompoundedComponent<ImageProps> = props => {
src: imgSrc,
isCustomPlaceholder,
fallback,
crossOrigin: props.crossOrigin,
});

const imgCommonProps = useMemo(
Expand Down Expand Up @@ -243,7 +246,7 @@ const ImageInternal: CompoundedComponent<ImageProps> = props => {
onClick={canPreview ? onPreview : onClick}
role={canPreview ? 'button' : otherProps.role}
tabIndex={canPreview && otherProps.tabIndex == null ? 0 : otherProps.tabIndex}
aria-label={canPreview ? otherProps['aria-label'] ?? alt : otherProps['aria-label']}
aria-label={canPreview ? (otherProps['aria-label'] ?? alt) : otherProps['aria-label']}
onKeyDown={onPreviewKeyDown}
style={{
width,
Expand Down
1 change: 1 addition & 0 deletions src/Preview/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,7 @@ const PreviewImage: React.FC<PreviewImageProps> = ({ fallback, src, imgRef, ...p
const [getImgRef, srcAndOnload] = useStatus({
src,
fallback,
crossOrigin: props.crossOrigin,
});

return (
Expand Down
8 changes: 5 additions & 3 deletions src/hooks/useStatus.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,12 @@ export default function useStatus({
src,
isCustomPlaceholder,
fallback,
crossOrigin,
}: {
src: string;
isCustomPlaceholder?: boolean;
fallback?: string;
crossOrigin?: string;
}) {
const [status, setStatus] = useState<ImageStatus>(isCustomPlaceholder ? 'loading' : 'normal');
const isLoaded = useRef(false);
Expand All @@ -19,7 +21,7 @@ export default function useStatus({
// https://github.com/react-component/image/pull/187
useEffect(() => {
let isCurrentSrc = true;
isImageValid(src).then(isValid => {
isImageValid(src, crossOrigin).then(isValid => {
// https://github.com/ant-design/ant-design/issues/44948
// If src changes, the previous setStatus should not be triggered
if (!isValid && isCurrentSrc) {
Expand All @@ -29,15 +31,15 @@ export default function useStatus({
return () => {
isCurrentSrc = false;
};
}, [src]);
}, [src, crossOrigin]);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved

useEffect(() => {
if (isCustomPlaceholder && !isLoaded.current) {
setStatus('loading');
} else if (isError) {
setStatus('normal');
}
}, [src]);
}, [src, crossOrigin]);

const onLoad = () => {
setStatus('normal');
Expand Down
5 changes: 4 additions & 1 deletion src/util.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
export function isImageValid(src: string) {
export function isImageValid(src: string, crossOrigin?: string) {
return new Promise(resolve => {
if (!src) {
resolve(false);
return;
}
const img = document.createElement('img');
if (crossOrigin !== undefined) {
img.crossOrigin = crossOrigin;
}
img.onerror = () => resolve(false);
img.onload = () => resolve(true);
img.src = src;
Expand Down
40 changes: 35 additions & 5 deletions tests/crossOrigin.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,14 +15,17 @@ describe('CrossOrigin', () => {
it('should not crash when window.top access throws SecurityError', () => {
// Mock window.top to throw SecurityError on access
const originalTop = window.top;

// Try to define a property that throws when accessed
// Note: In some jsdom environments window.top might be non-configurable.
// If this fails, we might need a different strategy, but this is the standard way to mock cross-origin.
try {
Object.defineProperty(window, 'top', {
get: () => {
throw new DOMException('Permission denied to access property "removeEventListener" on cross-origin object', 'SecurityError');
throw new DOMException(
'Permission denied to access property "removeEventListener" on cross-origin object',
'SecurityError',
);
},
configurable: true,
});
Expand All @@ -35,9 +38,7 @@ describe('CrossOrigin', () => {
}

const { container, unmount } = render(
<Image
src="https://zos.alipayobjects.com/rmsportal/jkjgkEfvpUPVyRjUImniVslZfWPnJuuZ.png"
/>,
<Image src="https://zos.alipayobjects.com/rmsportal/jkjgkEfvpUPVyRjUImniVslZfWPnJuuZ.png" />,
);

// Open preview to trigger the effect that binds events
Expand All @@ -57,4 +58,33 @@ describe('CrossOrigin', () => {
configurable: true,
});
});

it('should pass crossOrigin to isImageValid check', () => {
let probeCrossOrigin: string | undefined;
const originalCreateElement = document.createElement.bind(document);
jest.spyOn(document, 'createElement').mockImplementation((tagName: string) => {
const el = originalCreateElement(tagName);
if (tagName.toLowerCase() === 'img') {
const descriptor = Object.getOwnPropertyDescriptor(HTMLImageElement.prototype, 'src');
if (descriptor && descriptor.set) {
Object.defineProperty(el, 'src', {
set(val) {
probeCrossOrigin = el.crossOrigin;
descriptor.set!.call(el, val);
},
});
}
}
return el;
});

render(
<Image
src="https://zos.alipayobjects.com/rmsportal/jkjgkEfvpUPVyRjUImniVslZfWPnJuuZ.png"
crossOrigin="anonymous"
/>,
);

expect(probeCrossOrigin).toBe('anonymous');
});
});
66 changes: 66 additions & 0 deletions tests/useStatus.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { act, renderHook } from '@testing-library/react';
import useStatus from '../src/hooks/useStatus';
import { isImageValid } from '../src/util';

jest.mock('../src/util', () => ({
...jest.requireActual('../src/util'),
isImageValid: jest.fn(),
}));

const validate = isImageValid as jest.Mock;

describe('crossOrigin status changes', () => {
beforeEach(() => validate.mockReset());

it('restores the original image after a new policy succeeds', async () => {
validate.mockResolvedValueOnce(false);
const { result, rerender } = renderHook(
({ crossOrigin }) =>
useStatus({ src: 'original.png', fallback: 'fallback.png', crossOrigin }),
{ initialProps: { crossOrigin: 'anonymous' } },
);
await act(async () => {});
expect(result.current[1].src).toBe('fallback.png');

validate.mockResolvedValueOnce(true);
rerender({ crossOrigin: 'use-credentials' });
await act(async () => {});
expect(result.current[1].src).toBe('original.png');
expect(result.current[2]).toBe('normal');
});

it('keeps a custom placeholder until the image load event', async () => {
validate.mockResolvedValue(true);
const { result, rerender } = renderHook(
({ crossOrigin }) =>
useStatus({ src: 'original.png', crossOrigin, isCustomPlaceholder: true }),
{ initialProps: { crossOrigin: 'anonymous' } },
);
await act(async () => {});
rerender({ crossOrigin: 'use-credentials' });
await act(async () => {});
expect(result.current[2]).toBe('loading');
act(() => result.current[1].onLoad?.());
expect(result.current[2]).toBe('normal');
});

it('ignores a failed validation from the previous policy', async () => {
let failPrevious: (value: boolean) => void;
validate.mockImplementationOnce(
() =>
new Promise(resolve => {
failPrevious = resolve;
}),
);
const { result, rerender } = renderHook(
({ crossOrigin }) =>
useStatus({ src: 'original.png', fallback: 'fallback.png', crossOrigin }),
{ initialProps: { crossOrigin: 'anonymous' } },
);
validate.mockResolvedValueOnce(true);
rerender({ crossOrigin: 'use-credentials' });
await act(async () => failPrevious(false));
expect(result.current[1].src).toBe('original.png');
expect(result.current[2]).toBe('normal');
});
});