From f2badf3266c9c558bf644f051f9fb560c7212656 Mon Sep 17 00:00:00 2001 From: Ferit Date: Sat, 24 Oct 2026 23:08:23 +0300 Subject: [PATCH 1/2] fix: pass crossOrigin to isImageValid check in useStatus --- src/Image.tsx | 9 ++++++--- src/Preview/index.tsx | 1 + src/hooks/useStatus.ts | 6 ++++-- src/util.ts | 5 ++++- tests/crossOrigin.test.tsx | 40 +++++++++++++++++++++++++++++++++----- 5 files changed, 50 insertions(+), 11 deletions(-) diff --git a/src/Image.tsx b/src/Image.tsx index 30ab9ce1..f732cf00 100644 --- a/src/Image.tsx +++ b/src/Image.tsx @@ -43,8 +43,10 @@ export interface PreviewConfig extends Omit, 'placeholder' | 'onClick' | 'onKeyDown'> { +export interface ImageProps extends Omit< + React.ImgHTMLAttributes, + 'placeholder' | 'onClick' | 'onKeyDown' +> { // Misc prefixCls?: string; previewPrefixCls?: string; @@ -159,6 +161,7 @@ const ImageInternal: CompoundedComponent = props => { src: imgSrc, isCustomPlaceholder, fallback, + crossOrigin: props.crossOrigin, }); const imgCommonProps = useMemo( @@ -243,7 +246,7 @@ const ImageInternal: CompoundedComponent = 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, diff --git a/src/Preview/index.tsx b/src/Preview/index.tsx index 03a43f47..afa67f69 100644 --- a/src/Preview/index.tsx +++ b/src/Preview/index.tsx @@ -153,6 +153,7 @@ const PreviewImage: React.FC = ({ fallback, src, imgRef, ...p const [getImgRef, srcAndOnload] = useStatus({ src, fallback, + crossOrigin: props.crossOrigin, }); return ( diff --git a/src/hooks/useStatus.ts b/src/hooks/useStatus.ts index b57950c4..b95e7ef1 100644 --- a/src/hooks/useStatus.ts +++ b/src/hooks/useStatus.ts @@ -7,10 +7,12 @@ export default function useStatus({ src, isCustomPlaceholder, fallback, + crossOrigin, }: { src: string; isCustomPlaceholder?: boolean; fallback?: string; + crossOrigin?: string; }) { const [status, setStatus] = useState(isCustomPlaceholder ? 'loading' : 'normal'); const isLoaded = useRef(false); @@ -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) { @@ -29,7 +31,7 @@ export default function useStatus({ return () => { isCurrentSrc = false; }; - }, [src]); + }, [src, crossOrigin]); useEffect(() => { if (isCustomPlaceholder && !isLoaded.current) { diff --git a/src/util.ts b/src/util.ts index e0b77aee..12b89bf0 100644 --- a/src/util.ts +++ b/src/util.ts @@ -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; diff --git a/tests/crossOrigin.test.tsx b/tests/crossOrigin.test.tsx index b9710b3a..f86ff562 100644 --- a/tests/crossOrigin.test.tsx +++ b/tests/crossOrigin.test.tsx @@ -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, }); @@ -35,9 +38,7 @@ describe('CrossOrigin', () => { } const { container, unmount } = render( - , + , ); // Open preview to trigger the effect that binds events @@ -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( + , + ); + + expect(probeCrossOrigin).toBe('anonymous'); + }); }); From c8459d87730271ba0d14447e3ce38a5b9a658c6f Mon Sep 17 00:00:00 2001 From: Ferit Date: Tue, 27 Oct 2026 01:29:53 +0200 Subject: [PATCH 2/2] fix: reset image status when cross-origin policy changes --- src/hooks/useStatus.ts | 2 +- tests/useStatus.test.tsx | 66 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 67 insertions(+), 1 deletion(-) create mode 100644 tests/useStatus.test.tsx diff --git a/src/hooks/useStatus.ts b/src/hooks/useStatus.ts index b95e7ef1..47a6bbf3 100644 --- a/src/hooks/useStatus.ts +++ b/src/hooks/useStatus.ts @@ -39,7 +39,7 @@ export default function useStatus({ } else if (isError) { setStatus('normal'); } - }, [src]); + }, [src, crossOrigin]); const onLoad = () => { setStatus('normal'); diff --git a/tests/useStatus.test.tsx b/tests/useStatus.test.tsx new file mode 100644 index 00000000..c93308c1 --- /dev/null +++ b/tests/useStatus.test.tsx @@ -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'); + }); +});