Skip to content

Commit cbf0dfe

Browse files
authored
fix(ui5-avatar): add dynamic image error handling with fallback logic (#11642)
This commit fixes image error handling in the Avatar component to ensure fallback content is displayed when images fail to load. **Problem Fixed:** - Avatar would show broken/empty content when image sources were invalid - No automatic fallback when images failed to load dynamically - Missing error detection for already-broken images at initialization **Solution:** - **Dynamic Image Error Detection**: Added real-time monitoring of image load states with automatic fallback switching - **Reactive Error Handling**: Image failures now trigger immediate UI updates to show appropriate fallback content - **Comprehensive Error Detection**: Handles both runtime failures and already-broken images via `complete` and `naturalWidth` checks **Technical Implementation:** - Added `_imageLoadError` reactive property to track image state changes - Implemented event listeners for `load` and `error` events on image elements - Enhanced `hasImage` getter to consider both image presence and load state - Added proper event listener lifecycle management to prevent memory leaks - Optimized re-rendering with cached `_hasImage` property in template **Fallback Hierarchy (unchanged):** 1. Image (if provided and loads successfully) 2. Icon (if `icon` property is set) 3. Initials (if valid and fit) 4. Fallback Icon (final fallback) **Edge Cases Handled:** - Images that fail after initial load (dynamic URL changes) - Images that are already broken when component initializes - Seamless switching between broken → fixed → broken states **Files Modified:** - Avatar.ts: Core image error handling and reactive state management - AvatarTemplate.tsx: Updated to use cached `_hasImage` property - Avatar.cy.tsx: Added comprehensive test coverage for error scenarios Fixes: #11140
1 parent f32bf1d commit cbf0dfe

File tree

4 files changed

+523
-11
lines changed

4 files changed

+523
-11
lines changed

packages/main/cypress/specs/Avatar.cy.tsx

Lines changed: 253 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ import Avatar from "../../src/Avatar.js";
22
import Tag from "../../src/Tag.js";
33
import Icon from "../../src/Icon.js";
44
import "@ui5/webcomponents-icons/dist/supplier.js";
5+
import "@ui5/webcomponents-icons/dist/alert.js";
6+
import "@ui5/webcomponents-icons/dist/person-placeholder.js";
57

68
describe("Accessibility", () => {
79
it("checks if initials of avatar are correctly announced", () => {
@@ -53,3 +55,254 @@ describe("Accessibility", () => {
5355
cy.get("#click-event").should("have.value", "0");
5456
});
5557
});
58+
59+
describe("Fallback Logic", () => {
60+
it("shows image when valid image is provided", () => {
61+
cy.mount(
62+
<Avatar id="avatar-with-image">
63+
<img src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==" alt="Test" />
64+
</Avatar>
65+
);
66+
67+
cy.get("#avatar-with-image")
68+
.shadow()
69+
.find(".ui5-avatar-root slot:not([name])")
70+
.should("exist");
71+
72+
cy.get("#avatar-with-image")
73+
.shadow()
74+
.find(".ui5-avatar-icon-fallback")
75+
.should("not.exist");
76+
});
77+
78+
it("shows fallback icon when image fails to load", () => {
79+
cy.mount(
80+
<Avatar id="avatar-broken-image">
81+
<img src="./invalid-image.png" alt="Broken" />
82+
</Avatar>
83+
);
84+
85+
// Wait for image error to trigger
86+
cy.wait(100);
87+
88+
cy.get("#avatar-broken-image")
89+
.shadow()
90+
.find(".ui5-avatar-icon-fallback")
91+
.should("exist")
92+
.and("be.visible");
93+
});
94+
95+
it("shows custom icon when icon property is set (no image)", () => {
96+
cy.mount(<Avatar id="avatar-with-icon" icon="supplier"></Avatar>);
97+
98+
cy.get("#avatar-with-icon")
99+
.shadow()
100+
.find(".ui5-avatar-icon")
101+
.should("exist")
102+
.and("be.visible")
103+
.and("have.attr", "name", "supplier");
104+
105+
cy.get("#avatar-with-icon")
106+
.shadow()
107+
.find(".ui5-avatar-icon-fallback")
108+
.should("not.be.visible");
109+
});
110+
111+
it("shows valid initials when provided (no image, no icon)", () => {
112+
cy.mount(<Avatar id="avatar-with-initials" initials="JD"></Avatar>);
113+
114+
cy.get("#avatar-with-initials")
115+
.shadow()
116+
.find(".ui5-avatar-initials")
117+
.should("exist")
118+
.and("contain.text", "JD");
119+
120+
cy.get("#avatar-with-initials")
121+
.shadow()
122+
.find(".ui5-avatar-fallback-icon-hidden")
123+
.should("exist");
124+
});
125+
126+
it("shows fallback icon for invalid initials (too many characters)", () => {
127+
cy.mount(<Avatar id="avatar-invalid-initials" initials="ABCD"></Avatar>);
128+
129+
cy.get("#avatar-invalid-initials")
130+
.shadow()
131+
.find(".ui5-avatar-icon-fallback")
132+
.should("exist")
133+
.and("be.visible");
134+
135+
cy.get("#avatar-invalid-initials")
136+
.shadow()
137+
.find(".ui5-avatar-initials")
138+
.should("have.class", "ui5-avatar-initials-hidden");
139+
});
140+
141+
it("shows fallback icon for non-Latin initials", () => {
142+
cy.mount(<Avatar id="avatar-non-latin" initials="АБ"></Avatar>);
143+
144+
cy.get("#avatar-non-latin")
145+
.shadow()
146+
.find(".ui5-avatar-icon-fallback")
147+
.should("exist")
148+
.and("be.visible");
149+
});
150+
151+
it("shows custom fallback icon when specified", () => {
152+
cy.mount(<Avatar id="avatar-custom-fallback" fallbackIcon="alert"></Avatar>);
153+
154+
cy.get("#avatar-custom-fallback")
155+
.shadow()
156+
.find(".ui5-avatar-icon-fallback")
157+
.should("exist")
158+
.and("have.attr", "name", "alert");
159+
});
160+
161+
it("prioritizes image over icon", () => {
162+
cy.mount(
163+
<Avatar id="avatar-image-over-icon" icon="supplier">
164+
<img src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==" alt="Test" />
165+
</Avatar>
166+
);
167+
168+
// Should show image, not icon
169+
cy.get("#avatar-image-over-icon")
170+
.shadow()
171+
.find("slot:not([name])")
172+
.should("exist");
173+
174+
cy.get("#avatar-image-over-icon")
175+
.shadow()
176+
.find(".ui5-avatar-icon")
177+
.should("not.exist");
178+
});
179+
180+
it("prioritizes icon over initials", () => {
181+
cy.mount(<Avatar id="avatar-icon-over-initials" icon="supplier" initials="JD"></Avatar>);
182+
183+
cy.get("#avatar-icon-over-initials")
184+
.shadow()
185+
.find(".ui5-avatar-icon")
186+
.should("exist")
187+
.and("have.attr", "name", "supplier");
188+
189+
cy.get("#avatar-icon-over-initials")
190+
.shadow()
191+
.find(".ui5-avatar-initials")
192+
.should("have.class", "ui5-avatar-initials-hidden");
193+
});
194+
195+
it("switches from image to fallback when image fails dynamically", () => {
196+
cy.mount(
197+
<Avatar id="avatar-dynamic-fail">
198+
<img src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==" alt="Test" />
199+
</Avatar>
200+
);
201+
202+
// Initially should show image
203+
cy.get("#avatar-dynamic-fail")
204+
.shadow()
205+
.find("slot:not([name])")
206+
.should("exist");
207+
208+
// Change image source to broken URL
209+
cy.get("#avatar-dynamic-fail img").then(($img) => {
210+
($img[0] as HTMLImageElement).src = "./broken-image.png";
211+
});
212+
213+
// Wait for error handling
214+
cy.wait(100);
215+
216+
// Should now show fallback icon
217+
cy.get("#avatar-dynamic-fail")
218+
.shadow()
219+
.find(".ui5-avatar-icon-fallback")
220+
.should("exist")
221+
.and("be.visible");
222+
});
223+
224+
it("switches from fallback back to image when image is fixed", () => {
225+
cy.mount(
226+
<Avatar id="avatar-dynamic-fix">
227+
<img src="./broken-image.png" alt="Initially broken" />
228+
</Avatar>
229+
);
230+
231+
// Wait for initial error
232+
cy.wait(100);
233+
234+
// Should show fallback initially
235+
cy.get("#avatar-dynamic-fix")
236+
.shadow()
237+
.find(".ui5-avatar-icon-fallback")
238+
.should("exist");
239+
240+
// Fix the image source
241+
cy.get("#avatar-dynamic-fix img").then(($img) => {
242+
($img[0] as HTMLImageElement).src = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==";
243+
});
244+
245+
// Wait for load handling
246+
cy.wait(100);
247+
248+
// Should now show image
249+
cy.get("#avatar-dynamic-fix")
250+
.shadow()
251+
.find(".ui5-avatar-icon-fallback")
252+
.should("not.exist");
253+
});
254+
255+
it("shows fallback icon when no content is provided", () => {
256+
cy.mount(<Avatar id="avatar-empty"></Avatar>);
257+
258+
cy.get("#avatar-empty")
259+
.shadow()
260+
.find(".ui5-avatar-icon-fallback")
261+
.should("exist")
262+
.and("be.visible")
263+
.and("have.attr", "name", "employee"); // Default fallback icon
264+
});
265+
266+
it("correctly handles initials that don't fit in small sizes", () => {
267+
cy.mount(<Avatar id="avatar-tight-initials" initials="WWW" size="XS"></Avatar>);
268+
269+
// For XS size, "WWW" likely won't fit, so should show fallback
270+
cy.get("#avatar-tight-initials")
271+
.shadow()
272+
.find(".ui5-avatar-initials")
273+
.should("have.class", "ui5-avatar-initials-hidden");
274+
275+
cy.get("#avatar-tight-initials")
276+
.shadow()
277+
.find(".ui5-avatar-icon-fallback")
278+
.should("not.have.class", "ui5-avatar-fallback-icon-hidden");
279+
});
280+
281+
it("maintains fallback hierarchy: Image > Icon > Initials > Fallback Icon", () => {
282+
// Test with broken image, should show icon
283+
cy.mount(
284+
<Avatar id="hierarchy-test" icon="supplier" initials="JD">
285+
<img src="./broken-image.png" alt="Broken" />
286+
</Avatar>
287+
);
288+
289+
cy.wait(100);
290+
291+
// Should show icon (not initials or fallback) when image fails
292+
cy.get("#hierarchy-test")
293+
.shadow()
294+
.find(".ui5-avatar-icon")
295+
.should("exist")
296+
.and("have.attr", "name", "supplier");
297+
298+
cy.get("#hierarchy-test")
299+
.shadow()
300+
.find(".ui5-avatar-initials")
301+
.should("have.class", "ui5-avatar-initials-hidden");
302+
303+
cy.get("#hierarchy-test")
304+
.shadow()
305+
.find(".ui5-avatar-icon-fallback")
306+
.should("have.class", "ui5-avatar-fallback-icon-hidden");
307+
});
308+
});

packages/main/src/Avatar.ts

Lines changed: 80 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -215,6 +215,12 @@ class Avatar extends UI5Element implements ITabbable, IAvatarGroupItem {
215215
@property({ type: Boolean })
216216
_hasImage = false;
217217

218+
/**
219+
* @private
220+
*/
221+
@property({ type: Boolean, noAttribute: true })
222+
_imageLoadError = false;
223+
218224
/**
219225
* Receives the desired `<img>` tag
220226
*
@@ -244,13 +250,19 @@ class Avatar extends UI5Element implements ITabbable, IAvatarGroupItem {
244250
static i18nBundle: I18nBundle;
245251

246252
_handleResizeBound: ResizeObserverCallback;
253+
_onImageLoadBound: (e: Event) => void;
254+
_onImageErrorBound: (e: Event) => void;
247255

248256
constructor() {
249257
super();
258+
250259
this._handleResizeBound = this.handleResize.bind(this);
260+
this._onImageLoadBound = this._onImageLoad.bind(this);
261+
this._onImageErrorBound = this._onImageError.bind(this);
251262
}
252263

253264
onBeforeRendering() {
265+
this._attachImageEventHandlers();
254266
this._hasImage = this.hasImage;
255267
}
256268

@@ -316,7 +328,11 @@ class Avatar extends UI5Element implements ITabbable, IAvatarGroupItem {
316328
}
317329

318330
get hasImage() {
319-
return !!this.image.length;
331+
return !!this.image.length && !this._imageLoadError;
332+
}
333+
334+
get imageEl(): HTMLImageElement | null {
335+
return this.image?.[0] instanceof HTMLImageElement ? this.image[0] : null;
320336
}
321337

322338
get initialsContainer(): HTMLObjectElement | null {
@@ -346,6 +362,8 @@ class Avatar extends UI5Element implements ITabbable, IAvatarGroupItem {
346362
onExitDOM() {
347363
this.initialsContainer && ResizeHandler.deregister(this.initialsContainer,
348364
this._handleResizeBound);
365+
366+
this._detachImageEventHandlers();
349367
}
350368

351369
handleResize() {
@@ -414,6 +432,67 @@ class Avatar extends UI5Element implements ITabbable, IAvatarGroupItem {
414432

415433
return ariaHaspopup;
416434
}
435+
436+
_attachImageEventHandlers() {
437+
const imgEl = this.imageEl;
438+
if (!imgEl) {
439+
this._imageLoadError = false;
440+
return;
441+
}
442+
443+
// Remove previous handlers to avoid duplicates
444+
imgEl.removeEventListener("load", this._onImageLoadBound);
445+
imgEl.removeEventListener("error", this._onImageErrorBound);
446+
447+
// Attach new handlers
448+
imgEl.addEventListener("load", this._onImageLoadBound);
449+
imgEl.addEventListener("error", this._onImageErrorBound);
450+
451+
// Check existing image state
452+
this._checkExistingImageState();
453+
}
454+
455+
_checkExistingImageState() {
456+
const imgEl = this.imageEl;
457+
if (!imgEl) {
458+
this._imageLoadError = false;
459+
return;
460+
}
461+
462+
if (imgEl.complete && imgEl.naturalWidth === 0) {
463+
this._imageLoadError = true; // Already broken
464+
} else if (imgEl.complete && imgEl.naturalWidth > 0) {
465+
this._imageLoadError = false; // Already loaded
466+
} else {
467+
this._imageLoadError = false; // Pending load
468+
}
469+
}
470+
471+
_detachImageEventHandlers() {
472+
const imgEl = this.imageEl;
473+
if (!imgEl) {
474+
return;
475+
}
476+
477+
imgEl.removeEventListener("load", this._onImageLoadBound);
478+
imgEl.removeEventListener("error", this._onImageErrorBound);
479+
}
480+
481+
_onImageLoad(e: Event) {
482+
if (e.target !== this.imageEl) {
483+
(e.target as HTMLImageElement)?.removeEventListener("load", this._onImageLoadBound);
484+
return;
485+
}
486+
this._imageLoadError = false;
487+
}
488+
489+
_onImageError(e: Event) {
490+
if (e.target !== this.imageEl) {
491+
(e.target as HTMLImageElement)?.removeEventListener("error", this._onImageErrorBound);
492+
return;
493+
}
494+
this._imageLoadError = true;
495+
}
417496
}
418497

419498
Avatar.define();

0 commit comments

Comments
 (0)