Skip to content

Commit 06ac7da

Browse files
authored
Merge pull request #396 from opf/bug/69230-invalid-src-fallback
Bug/69230 Handle 404 errors in AvatarWithFallback with client-side fallback
2 parents e7b7cff + a0d7fbd commit 06ac7da

10 files changed

Lines changed: 167 additions & 15 deletions

File tree

.changeset/olive-eyes-juggle.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@openproject/primer-view-components': patch
3+
---
4+
5+
Handle 404 errors in AvatarWithFallback with client-side fallback

app/components/primer/open_project/avatar_fallback.ts

Lines changed: 49 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,16 +4,57 @@ import {attr, controller} from '@github/catalyst'
44
export class AvatarFallbackElement extends HTMLElement {
55
@attr uniqueId = ''
66
@attr altText = ''
7+
@attr fallbackSrc = ''
8+
9+
private img: HTMLImageElement | null = null
10+
private boundErrorHandler?: () => void
711

812
connectedCallback() {
13+
this.img = this.querySelector<HTMLImageElement>('img') ?? null
14+
if (!this.img) return
15+
16+
this.boundErrorHandler = () => this.handleImageError(this.img!)
17+
18+
// Handle image load errors (404, network failure, etc.)
19+
this.img.addEventListener('error', this.boundErrorHandler)
20+
21+
// Check if image already failed (error event fired before listener attached)
22+
if (this.isImageBroken(this.img)) {
23+
this.handleImageError(this.img)
24+
} else if (this.isFallbackImage(this.img)) {
25+
this.applyColor(this.img)
26+
}
27+
}
28+
29+
disconnectedCallback() {
30+
if (this.boundErrorHandler && this.img) {
31+
this.img.removeEventListener('error', this.boundErrorHandler)
32+
}
33+
this.boundErrorHandler = undefined
34+
this.img = null
35+
}
36+
37+
private isImageBroken(img: HTMLImageElement): boolean {
38+
// Image is broken if loading completed but no actual image data loaded
39+
// Skip check for data URIs (fallback SVGs) as they're always valid
40+
return img.complete && img.naturalWidth === 0 && !img.src.startsWith('data:')
41+
}
42+
43+
private handleImageError(img: HTMLImageElement) {
44+
// Prevent infinite loop if fallback also fails
45+
if (this.isFallbackImage(img)) return
46+
47+
if (this.fallbackSrc) {
48+
img.src = this.fallbackSrc
49+
this.applyColor(img)
50+
}
51+
}
52+
53+
private applyColor(img: HTMLImageElement) {
954
// If either uniqueId or altText is missing, skip color customization so the SVG
1055
// keeps its default gray fill defined in the source and no color override is applied.
1156
if (!this.uniqueId || !this.altText) return
1257

13-
const img = this.querySelector<HTMLImageElement>('img[src^="data:image/svg+xml"]')
14-
if (!img) return
15-
16-
// Generate consistent color based on uniqueId and altText (hash must match OP Core)
1758
const text = `${this.uniqueId}${this.altText}`
1859
const hue = this.valueHash(text)
1960
const color = `hsl(${hue}, 50%, 30%)`
@@ -46,4 +87,8 @@ export class AvatarFallbackElement extends HTMLElement {
4687
// to avoid breaking the component.
4788
}
4889
}
90+
91+
private isFallbackImage(img: HTMLImageElement): boolean {
92+
return img.src === this.fallbackSrc
93+
}
4994
}

app/components/primer/open_project/avatar_with_fallback.rb

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ class AvatarWithFallback < Primer::Beta::Avatar
2121
# - https://github.com/primer/css/blob/main/src/support/variables/typography.scss
2222
FONT_STACK = "-apple-system, BlinkMacSystemFont, 'Segoe UI', 'Noto Sans', Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji'"
2323

24-
# @param src [String] The source url of the avatar image. When nil, renders a fallback with initials.
24+
# @param src [String] The source url of the avatar image. When nil or a broken URL, it renders a fallback with initials.
2525
# @param alt [String] Alt text for the avatar. Used for accessibility and to generate initials when src is nil.
2626
# @param size [Integer] <%= one_of(Primer::Beta::Avatar::SIZE_OPTIONS) %>
2727
# @param shape [Symbol] Shape of the avatar. <%= one_of(Primer::Beta::Avatar::SHAPE_OPTIONS) %>
@@ -32,20 +32,20 @@ def initialize(src: nil, alt: nil, size: DEFAULT_SIZE, shape: DEFAULT_SHAPE, hre
3232
require_src_or_alt_arguments(src, alt)
3333

3434
@unique_id = unique_id
35-
@use_fallback = src.blank?
36-
final_src = @use_fallback ? generate_fallback_svg(alt, size) : src
35+
@fallback_svg = generate_fallback_svg(alt, size)
36+
final_src = src.blank? ? @fallback_svg : src
3737

3838
super(src: final_src, alt: alt, size: size, shape: shape, href: href, **system_arguments)
3939
end
4040

4141
def call
4242
render(
43-
Primer::ConditionalWrapper.new(
44-
condition: @use_fallback,
43+
Primer::BaseComponent.new(
4544
tag: :"avatar-fallback",
4645
data: {
4746
unique_id: @unique_id,
48-
alt_text: @system_arguments[:alt]
47+
alt_text: @system_arguments[:alt],
48+
fallback_src: @fallback_svg
4949
}
5050
)
5151
) { super }

previews/primer/open_project/avatar_with_fallback_preview.rb

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,26 @@ def fallback_as_link
6666
end
6767
#
6868
# @!endgroup
69+
70+
# @!group Error Handling (404 Fallback)
71+
#
72+
# @label Broken image (404)
73+
# @snapshot
74+
def broken_image_404
75+
# Uses a non-existent URL - will trigger error handler and show fallback SVG
76+
render(Primer::OpenProject::AvatarWithFallback.new(
77+
src: "/non-existent-avatar.png",
78+
alt: "User With Missing Avatar",
79+
unique_id: 42
80+
))
81+
end
82+
83+
# @label Multiple broken images
84+
def multiple_broken_images
85+
render_with_template(locals: {})
86+
end
87+
#
88+
# @!endgroup
6989
end
7090
end
7191
end
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
<div class="d-flex gap-3 flex-items-center">
2+
<p class="color-fg-muted">These avatars have broken image URLs and will fallback to SVG initials:</p>
3+
</div>
4+
<div class="d-flex gap-3 flex-items-center mt-2">
5+
<%= render(Primer::OpenProject::AvatarWithFallback.new(src: "/missing/alice.png", alt: "Alice Johnson", unique_id: 10)) %>
6+
<%= render(Primer::OpenProject::AvatarWithFallback.new(src: "/missing/bob.png", alt: "Bob Smith", unique_id: 20)) %>
7+
<%= render(Primer::OpenProject::AvatarWithFallback.new(src: "/missing/charlie.png", alt: "Charlie Brown", unique_id: 30)) %>
8+
<%= render(Primer::OpenProject::AvatarWithFallback.new(src: "https://example.com/404.png", alt: "Diana Prince", unique_id: 40)) %>
9+
<%= render(Primer::OpenProject::AvatarWithFallback.new(src: "https://invalid-domain-12345.com/avatar.png", alt: "Eve Anderson", unique_id: 50)) %>
10+
</div>

static/arguments.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5775,7 +5775,7 @@
57755775
"name": "src",
57765776
"type": "String",
57775777
"default": "`nil`",
5778-
"description": "The source url of the avatar image. When nil, renders a fallback with initials."
5778+
"description": "The source url of the avatar image. When nil or a broken URL, it renders a fallback with initials."
57795779
},
57805780
{
57815781
"name": "alt",

static/info_arch.json

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18882,7 +18882,7 @@
1888218882
"name": "src",
1888318883
"type": "String",
1888418884
"default": "`nil`",
18885-
"description": "The source url of the avatar image. When nil, renders a fallback with initials."
18885+
"description": "The source url of the avatar image. When nil or a broken URL, it renders a fallback with initials."
1888618886
},
1888718887
{
1888818888
"name": "alt",
@@ -19040,6 +19040,32 @@
1904019040
"color-contrast"
1904119041
]
1904219042
}
19043+
},
19044+
{
19045+
"preview_path": "primer/open_project/avatar_with_fallback/broken_image_404",
19046+
"name": "broken_image_404",
19047+
"snapshot": "true",
19048+
"skip_rules": {
19049+
"wont_fix": [
19050+
"region"
19051+
],
19052+
"will_fix": [
19053+
"color-contrast"
19054+
]
19055+
}
19056+
},
19057+
{
19058+
"preview_path": "primer/open_project/avatar_with_fallback/multiple_broken_images",
19059+
"name": "multiple_broken_images",
19060+
"snapshot": "false",
19061+
"skip_rules": {
19062+
"wont_fix": [
19063+
"region"
19064+
],
19065+
"will_fix": [
19066+
"color-contrast"
19067+
]
19068+
}
1904319069
}
1904419070
],
1904519071
"subcomponents": []

static/previews.json

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1633,6 +1633,32 @@
16331633
"color-contrast"
16341634
]
16351635
}
1636+
},
1637+
{
1638+
"preview_path": "primer/open_project/avatar_with_fallback/broken_image_404",
1639+
"name": "broken_image_404",
1640+
"snapshot": "true",
1641+
"skip_rules": {
1642+
"wont_fix": [
1643+
"region"
1644+
],
1645+
"will_fix": [
1646+
"color-contrast"
1647+
]
1648+
}
1649+
},
1650+
{
1651+
"preview_path": "primer/open_project/avatar_with_fallback/multiple_broken_images",
1652+
"name": "multiple_broken_images",
1653+
"snapshot": "false",
1654+
"skip_rules": {
1655+
"wont_fix": [
1656+
"region"
1657+
],
1658+
"will_fix": [
1659+
"color-contrast"
1660+
]
1661+
}
16361662
}
16371663
]
16381664
},

test/components/open_project/avatar_stack_test.rb

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,8 @@ def test_renders_mixed_avatars
4141
assert_selector(".AvatarStack-body") do
4242
# 2 img tags total: 1 with remote src, 1 with data URI fallback
4343
assert_selector("img.avatar", count: 2)
44-
assert_selector("avatar-fallback", count: 1)
44+
# All avatars wrapped in avatar-fallback for 404 error handling
45+
assert_selector("avatar-fallback", count: 2)
4546
assert_selector("img.avatar[src^='data:image/svg+xml;base64,']", count: 1)
4647
end
4748
end

test/components/open_project/avatar_with_fallback_test.rb

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,27 @@ class PrimerOpenProjectAvatarWithFallbackTest < Minitest::Test
88
def test_renders_image_avatar_with_src
99
render_inline(Primer::OpenProject::AvatarWithFallback.new(src: "https://github.com/github.png", alt: "github"))
1010

11-
assert_selector("img.avatar")
12-
refute_selector("avatar-fallback")
11+
# Always wrapped in avatar-fallback for 404 error handling
12+
assert_selector("avatar-fallback[data-fallback-src^='data:image/svg+xml;base64,']") do
13+
assert_selector("img.avatar[src='https://github.com/github.png']")
14+
end
15+
end
16+
17+
def test_image_avatar_error_handling_setup
18+
render_inline(Primer::OpenProject::AvatarWithFallback.new(src: "https://example.com/avatar.png", alt: "Alice Johnson", unique_id: 123))
19+
20+
# Original src is preserved (client-side JS handles error -> fallback swap)
21+
assert_selector("avatar-fallback[data-unique-id='123'][data-alt-text='Alice Johnson']") do
22+
assert_selector("img.avatar[src='https://example.com/avatar.png']")
23+
end
24+
25+
# Verify fallback SVG is available and contains correct initials
26+
fallback_wrapper = page.find("avatar-fallback")
27+
fallback_src = fallback_wrapper["data-fallback-src"]
28+
29+
assert fallback_src.start_with?("data:image/svg+xml;base64,")
30+
svg_content = Base64.decode64(fallback_src.sub("data:image/svg+xml;base64,", ""))
31+
assert_includes svg_content, ">AJ<", "Fallback SVG should contain initials 'AJ'"
1332
end
1433

1534
def test_renders_fallback_when_src_is_nil

0 commit comments

Comments
 (0)