Skip to content

Commit 890e9b0

Browse files
committed
Isolate image failure message per window
1 parent 906f5eb commit 890e9b0

8 files changed

Lines changed: 93 additions & 29 deletions

File tree

__tests__/src/components/ImageFailureMessage.test.jsx

Lines changed: 34 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -7,24 +7,23 @@ describe('ImageFailureMessage', () => {
77
it('does not render when no images have failed', () => {
88
const { container } = render(
99
<FailedImageProvider>
10-
<ImageFailureMessage />
10+
<ImageFailureMessage imageUrls={['https://example.com/image1.jpg']} />
1111
</FailedImageProvider>,
1212
);
1313

1414
expect(container).toBeEmptyDOMElement();
1515
});
1616

17-
it('renders default translated message when images have failed', () => {
18-
// Mock the context with hasFailed: true
17+
it('renders message when a specific image has failed', () => {
1918
const mockContext = {
19+
failedImages: new Set(['https://example.com/image1.jpg']),
2020
fallbackImage: 'data:image/svg+xml,...',
21-
hasFailed: true,
2221
notifyFailure: vi.fn(),
2322
};
2423

2524
render(
2625
<FailedImageContext.Provider value={mockContext}>
27-
<ImageFailureMessage />
26+
<ImageFailureMessage imageUrls={['https://example.com/image1.jpg']} />
2827
</FailedImageContext.Provider>,
2928
);
3029

@@ -33,16 +32,44 @@ describe('ImageFailureMessage', () => {
3332
expect(screen.getByText(/See console for details/i)).toBeInTheDocument();
3433
});
3534

35+
36+
it('isolates failures per window', () => {
37+
const mockContext = {
38+
failedImages: new Set(['https://example.com/manifest-a/canvas-1/image.jpg']),
39+
fallbackImage: 'data:image/svg+xml,...',
40+
notifyFailure: vi.fn(),
41+
};
42+
43+
// Render Window A with its failed image
44+
const { container: containerA } = render(
45+
<FailedImageContext.Provider value={mockContext}>
46+
<ImageFailureMessage imageUrls={['https://example.com/manifest-a/canvas-1/image.jpg']} />
47+
</FailedImageContext.Provider>,
48+
);
49+
50+
expect(containerA).not.toBeEmptyDOMElement();
51+
expect(screen.getByText(/Problem loading image/i)).toBeInTheDocument();
52+
53+
// Render Window B with non-failed image
54+
const { container: containerB } = render(
55+
<FailedImageContext.Provider value={mockContext}>
56+
<ImageFailureMessage imageUrls={['https://example.com/manifest-b/canvas-1/image.jpg']} />
57+
</FailedImageContext.Provider>,
58+
);
59+
60+
expect(containerB).toBeEmptyDOMElement();
61+
});
62+
3663
it('has proper accessibility attributes', () => {
3764
const mockContext = {
65+
failedImages: new Set(['https://example.com/image1.jpg']),
3866
fallbackImage: 'data:image/svg+xml,...',
39-
hasFailed: true,
4067
notifyFailure: vi.fn(),
4168
};
4269

4370
render(
4471
<FailedImageContext.Provider value={mockContext}>
45-
<ImageFailureMessage />
72+
<ImageFailureMessage imageUrls={['https://example.com/image1.jpg']} />
4673
</FailedImageContext.Provider>,
4774
);
4875

__tests__/src/components/OpenSeadragonViewer.test.js

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,10 @@ import { OpenSeadragonViewer } from '../../../src/components/OpenSeadragonViewer
77
import fixture from '../../fixtures/version-2/019.json';
88
import { OSDReferences } from '../../../src/plugins/OSDReferences';
99

10+
vi.mock('../../../src/components/ImageFailureMessage', () => ({
11+
ImageFailureMessage: vi.fn(() => null),
12+
}));
13+
1014
const canvases = Utils.parseManifest(fixture).getSequences()[0].getCanvases();
1115

1216
/**
@@ -115,4 +119,29 @@ describe('OpenSeadragonViewer', () => {
115119
vi.useRealTimers();
116120
});
117121
});
122+
123+
describe('ImageFailureMessage', () => {
124+
it('passes imageUrls from infoResponses and nonTiledImages', async () => {
125+
const { ImageFailureMessage } = await import('../../../src/components/ImageFailureMessage');
126+
127+
const infoResponses = [
128+
{ id: 'http://example.com/image1', json: {} },
129+
{ id: 'http://example.com/image2', json: {} },
130+
];
131+
const nonTiledImages = [
132+
{ id: 'http://example.com/image3', getProperty: () => 'Image' },
133+
];
134+
135+
createWrapper({ infoResponses, nonTiledImages });
136+
137+
// lastCall[0] returns props object
138+
expect(ImageFailureMessage.mock.lastCall[0]).toMatchObject({
139+
imageUrls: [
140+
'http://example.com/image1',
141+
'http://example.com/image2',
142+
'http://example.com/image3',
143+
],
144+
});
145+
});
146+
});
118147
});

__tests__/src/contexts/FailedImageProvider.test.jsx

Lines changed: 6 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -39,23 +39,18 @@ describe('FailedImageProvider', () => {
3939
expect(result.current.fallbackImage).toContain('%231967d2'); // Blue color
4040
});
4141

42-
it('provides hasFailed as false initially', () => {
42+
it('provides failedImages as empty Set initially and can add to it', () => {
4343
const wrapper = ({ children }) => <FailedImageProvider>{children}</FailedImageProvider>;
4444
const { result } = renderHook(() => useContext(FailedImageContext), { wrapper });
4545

46-
expect(result.current.hasFailed).toBe(false);
47-
});
48-
49-
it('sets hasFailed to true after notifyFailure is called', () => {
50-
const wrapper = ({ children }) => <FailedImageProvider>{children}</FailedImageProvider>;
51-
const { result } = renderHook(() => useContext(FailedImageContext), { wrapper });
52-
53-
expect(result.current.hasFailed).toBe(false);
46+
expect(result.current.failedImages).toBeInstanceOf(Set);
47+
expect(result.current.failedImages.size).toBe(0);
5448

5549
act(() => {
56-
result.current.notifyFailure();
50+
result.current.notifyFailure('https://example.com/image1.jpg');
5751
});
5852

59-
expect(result.current.hasFailed).toBe(true);
53+
expect(result.current.failedImages.size).toBe(1);
54+
expect(result.current.failedImages.has('https://example.com/image1.jpg')).toBe(true);
6055
});
6156
});

src/components/IIIFThumbnail.jsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -130,7 +130,7 @@ const LazyLoadedImage = ({
130130
style={imageStyles}
131131
onError={() => {
132132
setFailed(true);
133-
notifyFailure();
133+
if (finalSrc) notifyFailure(finalSrc);
134134
}}
135135
{...props}
136136
/>

src/components/ImageFailureMessage.jsx

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { useTranslation } from 'react-i18next';
33
import { styled } from '@mui/material/styles';
44
import Typography from '@mui/material/Typography';
55
import Box from '@mui/material/Box';
6+
import PropTypes from 'prop-types';
67
import FailedImageContext from '../contexts/FailedImageContext';
78

89
const MessageContainer = styled(Box)(({ theme }) => ({
@@ -21,11 +22,13 @@ const MessageContainer = styled(Box)(({ theme }) => ({
2122
/**
2223
* Displays an accessible message when images fail to load in the OSD viewer
2324
*/
24-
export function ImageFailureMessage() {
25-
const { hasFailed } = useContext(FailedImageContext);
25+
export function ImageFailureMessage({ imageUrls = [] }) {
26+
const { failedImages } = useContext(FailedImageContext);
2627
const { t } = useTranslation();
2728

28-
if (!hasFailed) return null;
29+
const hasFailedImage = imageUrls.some(url => failedImages.has(url));
30+
31+
if (!hasFailedImage) return null;
2932

3033
return (
3134
<MessageContainer role="status" aria-live="polite">
@@ -35,3 +38,7 @@ export function ImageFailureMessage() {
3538
</MessageContainer>
3639
);
3740
}
41+
42+
ImageFailureMessage.propTypes = {
43+
imageUrls: PropTypes.arrayOf(PropTypes.string),
44+
};

src/components/OpenSeadragonTileSource.jsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,8 @@ export default function OpenSeadragonTileSource({
6262
success: (event) => resolve(event),
6363

6464
error: (event) => {
65-
notifyFailure();
65+
const imageUrl = url || (typeof tileSource === 'string' ? tileSource : tileSource?.['@id']);
66+
if (imageUrl) notifyFailure(imageUrl);
6667
loadFallback();
6768

6869
reject(event);

src/components/OpenSeadragonViewer.jsx

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import {
2-
useRef, Children, cloneElement, useCallback, useState, useEffect,
2+
useRef, Children, cloneElement, useCallback, useState, useEffect, useMemo,
33
} from 'react';
44
import PropTypes from 'prop-types';
55
import { styled } from '@mui/material/styles';
@@ -85,6 +85,11 @@ export function OpenSeadragonViewer({
8585
...rest,
8686
};
8787

88+
const imageUrls = useMemo(() => [
89+
...infoResponses.map(info => info.id),
90+
...nonTiledImages.map(img => img.id),
91+
], [infoResponses, nonTiledImages]);
92+
8893
return (
8994
<OpenSeadragonComponent
9095
className={classNames(ns('osd-container'))}
@@ -138,7 +143,7 @@ export function OpenSeadragonViewer({
138143
{ drawAnnotations
139144
&& <AnnotationsOverlay viewer={viewer} windowId={windowId} /> }
140145
{ enhancedChildren }
141-
<ImageFailureMessage />
146+
<ImageFailureMessage imageUrls={imageUrls} />
142147
<PluginHook targetName="OpenSeadragonViewer" viewer={viewer} {...pluginProps} />
143148
</OpenSeadragonComponent>
144149
);

src/contexts/FailedImageProvider.jsx

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,16 +9,16 @@ const defaultFallback = 'data:image/svg+xml,%3Csvg xmlns="http://www.w3.org/2000
99

1010
export default function FailedImageProvider({ children }) {
1111
const fallbackImage = config.fallbackImage || defaultFallback;
12-
const [hasFailed, setHasFailed] = useState(false);
12+
const [failedImages, setFailedImages] = useState(new Set());
1313

14-
const notifyFailure = useCallback(() => {
15-
setHasFailed(true);
14+
const notifyFailure = useCallback((imageId) => {
15+
setFailedImages((prev) => new Set(prev).add(imageId));
1616
}, []);
1717

1818
return (
1919
<FailedImageContext.Provider value={{
20+
failedImages,
2021
fallbackImage,
21-
hasFailed,
2222
notifyFailure,
2323
}}
2424
>

0 commit comments

Comments
 (0)