From a13fb2c83827871e7d6907f6de6659c08a588f29 Mon Sep 17 00:00:00 2001 From: Aditya8369 Date: Sun, 9 Aug 2026 21:18:48 +0530 Subject: [PATCH] fix: External image fallback may not work reliably because it overwrites src without guarding --- ...LandingPageClient.avatar-fallback.test.tsx | 120 ++++++++++++++++++ app/components/LandingPageClient.tsx | 16 ++- 2 files changed, 134 insertions(+), 2 deletions(-) create mode 100644 app/components/LandingPageClient.avatar-fallback.test.tsx diff --git a/app/components/LandingPageClient.avatar-fallback.test.tsx b/app/components/LandingPageClient.avatar-fallback.test.tsx new file mode 100644 index 000000000..2ff9e6914 --- /dev/null +++ b/app/components/LandingPageClient.avatar-fallback.test.tsx @@ -0,0 +1,120 @@ +import { render, fireEvent } from '@testing-library/react'; +import { describe, it, expect, vi } from 'vitest'; +import React from 'react'; + +// Mock next/image to render native element +vi.mock('next/image', () => ({ + default: ({ + unoptimized: _u, + ...props + }: React.ImgHTMLAttributes & { unoptimized?: boolean }) => , +})); + +describe('Avatar Image Fallback Guarding', () => { + it('attempts fallback URL once on initial broken avatar image error', () => { + const userDetails = { + login: 'octocat', + avatar_url: 'https://avatars.githubusercontent.com/u/583231', + }; + + const handleAvatarError = (e: React.SyntheticEvent) => { + const img = e.currentTarget as HTMLImageElement; + if (img.dataset.fallbackAttempted === 'true') { + img.style.display = 'none'; + return; + } + img.dataset.fallbackAttempted = 'true'; + const fallbackUrl = `https://github.com/${userDetails.login}.png`; + if (img.src !== fallbackUrl && !img.src.endsWith(`/${userDetails.login}.png`)) { + img.src = fallbackUrl; + } else { + img.style.display = 'none'; + } + }; + + const { container } = render( + {userDetails.login} + ); + + const img = container.querySelector('img') as HTMLImageElement; + expect(img.dataset.fallbackAttempted).toBeUndefined(); + + // Trigger first error event (initial broken image) + fireEvent.error(img); + + expect(img.dataset.fallbackAttempted).toBe('true'); + expect(img.src).toBe('https://github.com/octocat.png'); + expect(img.style.display).not.toBe('none'); + }); + + it('stops overwriting src and hides image if fallback also fails', () => { + const userDetails = { + login: 'octocat', + avatar_url: 'https://avatars.githubusercontent.com/u/583231', + }; + + const handleAvatarError = (e: React.SyntheticEvent) => { + const img = e.currentTarget as HTMLImageElement; + if (img.dataset.fallbackAttempted === 'true') { + img.style.display = 'none'; + return; + } + img.dataset.fallbackAttempted = 'true'; + const fallbackUrl = `https://github.com/${userDetails.login}.png`; + if (img.src !== fallbackUrl && !img.src.endsWith(`/${userDetails.login}.png`)) { + img.src = fallbackUrl; + } else { + img.style.display = 'none'; + } + }; + + const { container } = render( + {userDetails.login} + ); + + const img = container.querySelector('img') as HTMLImageElement; + + // Trigger 1st error event (primary src broken) + fireEvent.error(img); + expect(img.dataset.fallbackAttempted).toBe('true'); + expect(img.src).toBe('https://github.com/octocat.png'); + + // Trigger 2nd error event (fallback src broken as well) + fireEvent.error(img); + + // Should stop mutating src and hide the element + expect(img.src).toBe('https://github.com/octocat.png'); + expect(img.style.display).toBe('none'); + }); + + it('immediately hides image if initial src is already the fallback URL and fails', () => { + const userDetails = { login: 'octocat', avatar_url: 'https://github.com/octocat.png' }; + + const handleAvatarError = (e: React.SyntheticEvent) => { + const img = e.currentTarget as HTMLImageElement; + if (img.dataset.fallbackAttempted === 'true') { + img.style.display = 'none'; + return; + } + img.dataset.fallbackAttempted = 'true'; + const fallbackUrl = `https://github.com/${userDetails.login}.png`; + if (img.src !== fallbackUrl && !img.src.endsWith(`/${userDetails.login}.png`)) { + img.src = fallbackUrl; + } else { + img.style.display = 'none'; + } + }; + + const { container } = render( + {userDetails.login} + ); + + const img = container.querySelector('img') as HTMLImageElement; + + // Trigger error event when initial src is already the fallback URL + fireEvent.error(img); + + expect(img.dataset.fallbackAttempted).toBe('true'); + expect(img.style.display).toBe('none'); + }); +}); diff --git a/app/components/LandingPageClient.tsx b/app/components/LandingPageClient.tsx index fdbf89262..ce6105c99 100644 --- a/app/components/LandingPageClient.tsx +++ b/app/components/LandingPageClient.tsx @@ -832,8 +832,20 @@ export default function LandingPageClient() { className="w-6 h-6 rounded-full border border-emerald-500/20 object-cover" onError={(e) => { const img = e.currentTarget as HTMLImageElement; - img.onerror = null; - img.src = `https://github.com/${userDetails.login}.png`; + if (img.dataset.fallbackAttempted === 'true') { + img.style.display = 'none'; + return; + } + img.dataset.fallbackAttempted = 'true'; + const fallbackUrl = `https://github.com/${userDetails.login}.png`; + if ( + img.src !== fallbackUrl && + !img.src.endsWith(`/${userDetails.login}.png`) + ) { + img.src = fallbackUrl; + } else { + img.style.display = 'none'; + } }} />