Skip to content

Commit 9ac633f

Browse files
akabiruclaude
andcommitted
Implement flicker-free "fallback first" avatar loading pattern
Render fallback SVG as initial <img> src (visible immediately), test load the real URL and replace if valid. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 60c1d2b commit 9ac633f

6 files changed

Lines changed: 102 additions & 81 deletions

File tree

.changeset/big-seals-clap.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+
Fix flickering in AvatarWithFallback client-side fallback handling for broken avatar urls
Lines changed: 44 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -1,61 +1,68 @@
1-
import {attr, controller} from '@github/catalyst'
2-
1+
import {controller} from '@github/catalyst'
2+
3+
/**
4+
* AvatarFallbackElement implements "fallback first" loading pattern:
5+
* 1. Fallback SVG is rendered immediately as <img> src
6+
* 2. Real avatar URL is test-loaded in background using new Image()
7+
* 3. On success, swaps to real image; on failure, fallback stays visible
8+
*
9+
* This approach prevents flicker by never showing a broken image state.
10+
* Inspired by OpenProject's Angular PrincipalRendererService.
11+
*
12+
* Note: We read attributes directly via getAttribute() instead of using @attr
13+
* due to a Catalyst bug where @attr accessors aren't properly initialized
14+
* when elements have pre-existing attribute values.
15+
*/
316
@controller
417
export class AvatarFallbackElement extends HTMLElement {
5-
@attr uniqueId = ''
6-
@attr altText = ''
7-
@attr fallbackSrc = ''
8-
918
private img: HTMLImageElement | null = null
10-
private boundErrorHandler?: () => void
1119

1220
connectedCallback() {
1321
this.img = this.querySelector<HTMLImageElement>('img') ?? null
1422
if (!this.img) return
1523

16-
this.boundErrorHandler = () => this.handleImageError(this.img!)
24+
const uniqueId = this.getAttribute('data-unique-id') || ''
25+
const altText = this.getAttribute('data-alt-text') || ''
26+
const avatarSrc = this.getAttribute('data-avatar-src') || ''
1727

18-
// Handle image load errors (404, network failure, etc.)
19-
this.img.addEventListener('error', this.boundErrorHandler)
28+
// Apply hashed color to fallback SVG immediately
29+
this.applyColor(this.img, uniqueId, altText)
2030

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)
31+
// Test-load real avatar URL in background
32+
if (avatarSrc) {
33+
this.testLoadImage(avatarSrc)
2634
}
2735
}
2836

2937
disconnectedCallback() {
30-
if (this.boundErrorHandler && this.img) {
31-
this.img.removeEventListener('error', this.boundErrorHandler)
32-
}
33-
this.boundErrorHandler = undefined
3438
this.img = null
3539
}
3640

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)
41+
/**
42+
* Test-loads the real avatar URL in background.
43+
* On success, swaps the visible img to the real URL.
44+
* On failure, does nothing - fallback stays visible.
45+
*/
46+
private testLoadImage(url: string) {
47+
const testImage = new Image()
48+
49+
testImage.onload = () => {
50+
// Success - swap to real image
51+
if (this.img) {
52+
this.img.src = url
53+
}
5054
}
55+
56+
// On error: do nothing, fallback stays visible (no flicker)
57+
testImage.src = url
5158
}
5259

53-
private applyColor(img: HTMLImageElement) {
60+
private applyColor(img: HTMLImageElement, uniqueId: string, altText: string) {
5461
// If either uniqueId or altText is missing, skip color customization so the SVG
5562
// keeps its default gray fill defined in the source and no color override is applied.
56-
if (!this.uniqueId || !this.altText) return
63+
if (!uniqueId || !altText) return
5764

58-
const text = `${this.uniqueId}${this.altText}`
65+
const text = `${uniqueId}${altText}`
5966
const hue = this.valueHash(text)
6067
const color = `hsl(${hue}, 50%, 30%)`
6168

@@ -76,6 +83,8 @@ export class AvatarFallbackElement extends HTMLElement {
7683

7784
private updateSvgColor(img: HTMLImageElement, color: string) {
7885
const dataUri = img.src
86+
if (!dataUri.startsWith('data:image/svg+xml;base64,')) return
87+
7988
const base64 = dataUri.replace('data:image/svg+xml;base64,', '')
8089

8190
try {
@@ -87,8 +96,4 @@ export class AvatarFallbackElement extends HTMLElement {
8796
// to avoid breaking the component.
8897
}
8998
}
90-
91-
private isFallbackImage(img: HTMLImageElement): boolean {
92-
return img.src === this.fallbackSrc
93-
}
9499
}

app/components/primer/open_project/avatar_with_fallback.rb

Lines changed: 11 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -5,14 +5,12 @@ module OpenProject
55
# OpenProject-specific Avatar component that extends Primer::Beta::Avatar
66
# to support fallback rendering with initials when no image source is provided.
77
#
8-
# When `src` is nil, this component renders an SVG with initials extracted from
9-
# the alt text. The AvatarFallbackElement web component then enhances it client-side
10-
# by applying a consistent background color based on the user's unique_id (using the
11-
# same hash function as OP Core for consistency).
8+
# Uses a "fallback first" pattern for flicker-free loading:
9+
# 1. Always renders fallback SVG as initial <img> src (visible immediately)
10+
# 2. Client-side JS test-loads the real URL in background
11+
# 3. On success, swaps to real image; on failure, fallback stays visible
1212
#
13-
# This component follows the "extension over mutation" pattern - it extends
14-
# Primer::Beta::Avatar without modifying its interface, ensuring compatibility
15-
# with upstream changes.
13+
# This approach is inspired by OpenProject's Angular PrincipalRendererService.
1614
class AvatarWithFallback < Primer::Beta::Avatar
1715
status :open_project
1816

@@ -21,8 +19,8 @@ class AvatarWithFallback < Primer::Beta::Avatar
2119
# - https://github.com/primer/css/blob/main/src/support/variables/typography.scss
2220
FONT_STACK = "-apple-system, BlinkMacSystemFont, 'Segoe UI', 'Noto Sans', Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji'"
2321

24-
# @param src [String] The source url of the avatar image. When nil or a broken URL, it renders a fallback with initials.
25-
# @param alt [String] Alt text for the avatar. Used for accessibility and to generate initials when src is nil.
22+
# @param src [String] The source url of the avatar image. JS will test-load and swap on success.
23+
# @param alt [String] Alt text for the avatar. Used for accessibility and to generate initials.
2624
# @param size [Integer] <%= one_of(Primer::Beta::Avatar::SIZE_OPTIONS) %>
2725
# @param shape [Symbol] Shape of the avatar. <%= one_of(Primer::Beta::Avatar::SHAPE_OPTIONS) %>
2826
# @param href [String] The URL to link to. If used, component will be wrapped by an `<a>` tag.
@@ -32,10 +30,11 @@ def initialize(src: nil, alt: nil, size: DEFAULT_SIZE, shape: DEFAULT_SHAPE, hre
3230
require_src_or_alt_arguments(src, alt)
3331

3432
@unique_id = unique_id
33+
@avatar_src = src.presence
3534
@fallback_svg = generate_fallback_svg(alt, size)
36-
final_src = src.blank? ? @fallback_svg : src
3735

38-
super(src: final_src, alt: alt, size: size, shape: shape, href: href, **system_arguments)
36+
# Always render fallback first - JS will swap to real image on successful load
37+
super(src: @fallback_svg, alt: alt, size: size, shape: shape, href: href, **system_arguments)
3938
end
4039

4140
def call
@@ -45,7 +44,7 @@ def call
4544
data: {
4645
unique_id: @unique_id,
4746
alt_text: @system_arguments[:alt],
48-
fallback_src: @fallback_svg
47+
avatar_src: @avatar_src
4948
}
5049
)
5150
) { super }

static/classes.json

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -597,7 +597,8 @@
597597
"Primer::OpenProject::SidePanel::Section"
598598
],
599599
"SkeletonBox": [
600-
"Primer::Alpha::SkeletonBox"
600+
"Primer::Alpha::SkeletonBox",
601+
"Primer::OpenProject::SkeletonBox"
601602
],
602603
"Stack": [
603604
"Primer::Alpha::Stack"
@@ -738,16 +739,20 @@
738739
"Primer::Alpha::ToggleSwitch"
739740
],
740741
"TreeItemSkeletonTextStyles": [
741-
"Primer::Alpha::TreeView"
742+
"Primer::Alpha::TreeView",
743+
"Primer::OpenProject::TreeView"
742744
],
743745
"TreeViewFailureMessage": [
744-
"Primer::Alpha::TreeView"
746+
"Primer::Alpha::TreeView",
747+
"Primer::OpenProject::TreeView"
745748
],
746749
"TreeViewRootUlStyles": [
747-
"Primer::Alpha::TreeView"
750+
"Primer::Alpha::TreeView",
751+
"Primer::OpenProject::TreeView"
748752
],
749753
"TreeViewSkeletonItemContainerStyle": [
750-
"Primer::Alpha::TreeView"
754+
"Primer::Alpha::TreeView",
755+
"Primer::OpenProject::TreeView"
751756
],
752757
"Truncate": [
753758
"Primer::Beta::Truncate"

test/components/open_project/avatar_stack_test.rb

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,10 @@ def test_renders_with_image_avatars
1212

1313
assert_selector("div.AvatarStack") do
1414
assert_selector(".AvatarStack-body") do
15-
assert_selector("img.avatar", count: 1)
15+
# Fallback-first pattern: img shows fallback SVG, real URL in data-avatar-src for JS
16+
assert_selector("avatar-fallback[data-avatar-src='https://github.com/github.png']") do
17+
assert_selector("img.avatar[src^='data:image/svg+xml;base64,']", count: 1)
18+
end
1619
end
1720
end
1821
end
@@ -39,11 +42,14 @@ def test_renders_mixed_avatars
3942

4043
assert_selector("div.AvatarStack") do
4144
assert_selector(".AvatarStack-body") do
42-
# 2 img tags total: 1 with remote src, 1 with data URI fallback
45+
# 2 img tags total, both with data URI fallback initially (fallback-first pattern)
4346
assert_selector("img.avatar", count: 2)
4447
# All avatars wrapped in avatar-fallback for 404 error handling
4548
assert_selector("avatar-fallback", count: 2)
46-
assert_selector("img.avatar[src^='data:image/svg+xml;base64,']", count: 1)
49+
# Both start with fallback SVG; JS swaps to real URL on successful load
50+
assert_selector("img.avatar[src^='data:image/svg+xml;base64,']", count: 2)
51+
# One has a real URL for JS to test-load
52+
assert_selector("avatar-fallback[data-avatar-src='https://github.com/github.png']", count: 1)
4753
end
4854
end
4955
end

test/components/open_project/avatar_with_fallback_test.rb

Lines changed: 23 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -5,45 +5,48 @@
55
class PrimerOpenProjectAvatarWithFallbackTest < Minitest::Test
66
include Primer::ComponentTestHelpers
77

8-
def test_renders_image_avatar_with_src
8+
# "Fallback First" pattern tests:
9+
# - img.src is ALWAYS the fallback SVG initially
10+
# - data-avatar-src contains the real URL for JS to test-load
11+
# - JS swaps to real image only on successful load
12+
13+
def test_renders_fallback_first_with_real_url_in_data_attribute
914
render_inline(Primer::OpenProject::AvatarWithFallback.new(src: "https://github.com/github.png", alt: "github"))
1015

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']")
16+
# Image always shows fallback SVG first
17+
assert_selector("avatar-fallback[data-avatar-src='https://github.com/github.png']") do
18+
assert_selector("img.avatar[src^='data:image/svg+xml;base64,']")
1419
end
1520
end
1621

17-
def test_image_avatar_error_handling_setup
22+
def test_preserves_unique_id_and_alt_text_for_client_side_processing
1823
render_inline(Primer::OpenProject::AvatarWithFallback.new(src: "https://example.com/avatar.png", alt: "Alice Johnson", unique_id: 123))
1924

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']")
25+
assert_selector("avatar-fallback[data-unique-id='123'][data-alt-text='Alice Johnson'][data-avatar-src='https://example.com/avatar.png']") do
26+
assert_selector("img.avatar[src^='data:image/svg+xml;base64,']")
2327
end
2428

25-
# Verify fallback SVG is available and contains correct initials
29+
# Verify fallback SVG contains correct initials
2630
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+
img = fallback_wrapper.find("img")
32+
svg_content = Base64.decode64(img["src"].sub("data:image/svg+xml;base64,", ""))
3133
assert_includes svg_content, ">AJ<", "Fallback SVG should contain initials 'AJ'"
3234
end
3335

3436
def test_renders_fallback_when_src_is_nil
3537
render_inline(Primer::OpenProject::AvatarWithFallback.new(src: nil, alt: "OpenProject Admin"))
3638

37-
# Should render avatar-fallback element wrapping an img with base64 SVG data URI
38-
assert_selector("avatar-fallback[data-alt-text='OpenProject Admin']") do
39+
# No data-avatar-src when src is nil
40+
assert_selector("avatar-fallback[data-alt-text='OpenProject Admin']:not([data-avatar-src])") do
3941
assert_selector("img.avatar[src^='data:image/svg+xml;base64,']")
4042
end
4143
end
4244

4345
def test_renders_fallback_when_src_is_blank
4446
render_inline(Primer::OpenProject::AvatarWithFallback.new(src: "", alt: "OpenProject Admin"))
4547

46-
assert_selector("avatar-fallback[data-alt-text='OpenProject Admin']") do
48+
# No data-avatar-src when src is blank
49+
assert_selector("avatar-fallback[data-alt-text='OpenProject Admin']:not([data-avatar-src])") do
4750
assert_selector("img.avatar[src^='data:image/svg+xml;base64,']")
4851
end
4952
end
@@ -101,7 +104,6 @@ def test_sets_size_height_and_width
101104
def test_fallback_sets_correct_size_class
102105
render_inline(Primer::OpenProject::AvatarWithFallback.new(src: nil, alt: "Test User", size: 40))
103106

104-
# Size is set via attributes, not a dedicated class
105107
assert_selector("img.avatar[size='40'][height='40'][width='40'][src^='data:image/svg+xml;base64,']")
106108
end
107109

@@ -132,7 +134,6 @@ def test_renders_link_wrapper
132134
def test_fallback_renders_link_wrapper
133135
render_inline(Primer::OpenProject::AvatarWithFallback.new(src: nil, alt: "Test User", href: "#test"))
134136

135-
# When href is provided, the avatar class is on the <a> tag, not the <img>
136137
assert_selector("avatar-fallback") do
137138
assert_selector("a.avatar[href='#test']") do
138139
assert_selector("img[src^='data:image/svg+xml;base64,']")
@@ -143,7 +144,6 @@ def test_fallback_renders_link_wrapper
143144
def test_fallback_with_unique_id_in_data_attribute
144145
render_inline(Primer::OpenProject::AvatarWithFallback.new(src: nil, alt: "Test User", unique_id: 123))
145146

146-
# Should have data attributes for client-side processing
147147
assert_selector("avatar-fallback[data-unique-id='123'][data-alt-text='Test User']") do
148148
assert_selector("img.avatar[src^='data:image/svg+xml;base64,']")
149149
end
@@ -189,9 +189,10 @@ def test_raises_when_both_src_and_alt_are_blank
189189
def test_fallback_with_single_word_name
190190
render_inline(Primer::OpenProject::AvatarWithFallback.new(src: nil, alt: "Alice"))
191191

192-
assert_selector("avatar-fallback") do
193-
assert_selector("img.avatar[src^='data:image/svg+xml;base64,']")
194-
end
192+
img = page.find("img.avatar")
193+
svg_content = Base64.decode64(img["src"].sub("data:image/svg+xml;base64,", ""))
194+
195+
assert_includes svg_content, ">A<", "Single word name should produce single initial 'A'"
195196
end
196197

197198
def test_status

0 commit comments

Comments
 (0)