From c1029327e72092c71412047f4bb4397f55004ed2 Mon Sep 17 00:00:00 2001 From: Jure <44338+hoornet@users.noreply.github.com> Date: Wed, 1 Apr 2026 12:09:57 +0200 Subject: [PATCH] Fix React error #31 crash from malformed Nostr profiles Malformed profiles with non-string fields (e.g. nip05: {}) crashed React's entire render tree in production. Add typeof guards and profileName() utility across all components that render profile data. Add ErrorBoundary in main.tsx to show crash details instead of blank screen. --- src/components/article/ArticleCard.tsx | 4 +-- src/components/article/ArticleView.tsx | 5 ++-- src/components/dm/DMView.tsx | 6 ++--- src/components/feed/ComposeBox.tsx | 6 ++--- src/components/feed/NoteActions.tsx | 5 ++-- src/components/feed/NoteCard.tsx | 8 +++--- src/components/feed/NoteContent.tsx | 3 ++- src/components/feed/TextSegments.tsx | 13 ++++----- src/components/follows/FollowsView.tsx | 8 +++--- src/components/profile/EditProfileForm.tsx | 17 ++++++------ src/components/profile/ProfileView.tsx | 16 +++++------ src/components/thread/AncestorChain.tsx | 6 ++--- src/components/thread/ThreadNode.tsx | 4 +-- src/components/zap/ZapHistoryView.tsx | 6 ++--- src/lib/utils.ts | 6 +++++ src/main.tsx | 31 ++++++++++++++++++++-- 16 files changed, 92 insertions(+), 52 deletions(-) diff --git a/src/components/article/ArticleCard.tsx b/src/components/article/ArticleCard.tsx index 9447352..9cdeaab 100644 --- a/src/components/article/ArticleCard.tsx +++ b/src/components/article/ArticleCard.tsx @@ -1,7 +1,7 @@ import { NDKEvent, nip19 } from "@nostr-dev-kit/ndk"; import { useProfile } from "../../hooks/useProfile"; import { useUIStore } from "../../stores/ui"; -import { shortenPubkey } from "../../lib/utils"; +import { shortenPubkey, profileName } from "../../lib/utils"; function getTag(event: NDKEvent, name: string): string { return event.tags.find((t) => t[0] === name)?.[1] ?? ""; @@ -32,7 +32,7 @@ export function ArticleCard({ event }: { event: NDKEvent }) { const publishedAt = parseInt(getTag(event, "published_at")) || event.created_at || null; const naddr = buildNaddr(event); - const authorName = profile?.displayName || profile?.name || shortenPubkey(event.pubkey); + const authorName = profileName(profile, shortenPubkey(event.pubkey)); const date = publishedAt ? new Date(publishedAt * 1000).toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" }) : null; diff --git a/src/components/article/ArticleView.tsx b/src/components/article/ArticleView.tsx index 268a36f..a0512be 100644 --- a/src/components/article/ArticleView.tsx +++ b/src/components/article/ArticleView.tsx @@ -8,6 +8,7 @@ import { useBookmarkStore } from "../../stores/bookmark"; import { fetchArticle, publishReaction, publishRepost, publishNote } from "../../lib/nostr"; import { nip19 } from "@nostr-dev-kit/ndk"; import { useProfile } from "../../hooks/useProfile"; +import { profileName } from "../../lib/utils"; import { ZapModal } from "../zap/ZapModal"; // ── Types ──────────────────────────────────────────────────────────────────── @@ -33,7 +34,7 @@ function getTags(event: NDKEvent, name: string): string[] { function AuthorRow({ pubkey, publishedAt, readingTime }: { pubkey: string; publishedAt: number | null; readingTime?: number }) { const { openProfile } = useUIStore(); const profile = useProfile(pubkey); - const name = profile?.displayName || profile?.name || pubkey.slice(0, 12) + "…"; + const name = profileName(profile, pubkey.slice(0, 12) + "…"); const date = publishedAt ? new Date(publishedAt * 1000).toLocaleDateString(undefined, { year: "numeric", month: "long", day: "numeric" }) : null; @@ -122,7 +123,7 @@ export function ArticleView() { const articleTags = event ? getTags(event, "t") : []; const authorPubkey = event?.pubkey ?? ""; const authorProfile = useProfile(authorPubkey); - const authorName = authorProfile?.displayName || authorProfile?.name || authorPubkey.slice(0, 12) + "…"; + const authorName = profileName(authorProfile, authorPubkey.slice(0, 12) + "…"); const bodyHtml = event?.content ? renderMarkdown(event.content) : ""; const wordCount = event?.content?.trim().split(/\s+/).length ?? 0; diff --git a/src/components/dm/DMView.tsx b/src/components/dm/DMView.tsx index 2ac4525..3ade4ee 100644 --- a/src/components/dm/DMView.tsx +++ b/src/components/dm/DMView.tsx @@ -5,7 +5,7 @@ import { useUIStore } from "../../stores/ui"; import { useNotificationsStore } from "../../stores/notifications"; import { fetchDMConversations, fetchDMThread, sendDM, decryptDM, getNDK } from "../../lib/nostr"; import { useProfile } from "../../hooks/useProfile"; -import { timeAgo, shortenPubkey } from "../../lib/utils"; +import { timeAgo, shortenPubkey, profileName } from "../../lib/utils"; // ── Helpers ────────────────────────────────────────────────────────────────── @@ -45,7 +45,7 @@ function ConvRow({ onSelect: () => void; }) { const profile = useProfile(partnerPubkey); - const name = profile?.displayName || profile?.name || shortenPubkey(partnerPubkey); + const name = profileName(profile, shortenPubkey(partnerPubkey)); const time = lastEvent.created_at ? timeAgo(lastEvent.created_at) : ""; return ( @@ -126,7 +126,7 @@ function ThreadPanel({ }) { const { openProfile } = useUIStore(); const profile = useProfile(partnerPubkey); - const name = profile?.displayName || profile?.name || shortenPubkey(partnerPubkey); + const name = profileName(profile, shortenPubkey(partnerPubkey)); const [messages, setMessages] = useState([]); const [loading, setLoading] = useState(true); diff --git a/src/components/feed/ComposeBox.tsx b/src/components/feed/ComposeBox.tsx index 3b2e9fd..6529dda 100644 --- a/src/components/feed/ComposeBox.tsx +++ b/src/components/feed/ComposeBox.tsx @@ -4,7 +4,7 @@ import { uploadImage, uploadBytes } from "../../lib/upload"; import { useAutoResize } from "../../hooks/useAutoResize"; import { useUserStore } from "../../stores/user"; import { useFeedStore } from "../../stores/feed"; -import { shortenPubkey } from "../../lib/utils"; +import { shortenPubkey, profileName } from "../../lib/utils"; import { open } from "@tauri-apps/plugin-dialog"; import { readFile } from "@tauri-apps/plugin-fs"; import { EmojiPicker } from "../shared/EmojiPicker"; @@ -25,8 +25,8 @@ export function ComposeBox({ onPublished, onNoteInjected }: { onPublished?: () = const textareaRef = useRef(null); const { profile, npub } = useUserStore(); - const avatar = profile?.picture; - const name = profile?.displayName || profile?.name || (npub ? shortenPubkey(npub) : ""); + const avatar = typeof profile?.picture === "string" ? profile.picture : undefined; + const name = profileName(profile, npub ? shortenPubkey(npub) : ""); // Auto-save draft with debounce useEffect(() => { diff --git a/src/components/feed/NoteActions.tsx b/src/components/feed/NoteActions.tsx index 3307e15..fb967cc 100644 --- a/src/components/feed/NoteActions.tsx +++ b/src/components/feed/NoteActions.tsx @@ -7,6 +7,7 @@ import { useZapCount } from "../../hooks/useZapCount"; import { useUserStore } from "../../stores/user"; import { useBookmarkStore } from "../../stores/bookmark"; import { publishReaction, publishRepost } from "../../lib/nostr"; +import { profileName } from "../../lib/utils"; import { ZapModal } from "../zap/ZapModal"; import { QuoteModal } from "./QuoteModal"; @@ -20,8 +21,8 @@ interface NoteActionsProps { export function NoteActions({ event, onReplyToggle, showReply }: NoteActionsProps) { const profile = useProfile(event.pubkey); - const name = profile?.displayName || profile?.name || event.pubkey.slice(0, 8) + "…"; - const avatar = profile?.picture; + const name = profileName(profile, event.pubkey.slice(0, 8) + "…"); + const avatar = typeof profile?.picture === "string" ? profile.picture : undefined; const { loggedIn } = useUserStore(); const { bookmarkedIds, addBookmark, removeBookmark } = useBookmarkStore(); const isBookmarked = bookmarkedIds.includes(event.id!); diff --git a/src/components/feed/NoteCard.tsx b/src/components/feed/NoteCard.tsx index 1b640fc..e637671 100644 --- a/src/components/feed/NoteCard.tsx +++ b/src/components/feed/NoteCard.tsx @@ -20,15 +20,17 @@ interface NoteCardProps { function ParentAuthorName({ pubkey }: { pubkey: string }) { const profile = useProfile(pubkey); - const name = profile?.displayName || profile?.name || pubkey.slice(0, 8) + "…"; + const raw = profile?.displayName || profile?.name; + const name = (typeof raw === "string" ? raw : null) || pubkey.slice(0, 8) + "…"; return @{name}; } export function NoteCard({ event, focused, onReplyInThread }: NoteCardProps) { const profile = useProfile(event.pubkey); - const name = profile?.displayName || profile?.name || shortenPubkey(event.pubkey); + const rawName = profile?.displayName || profile?.name; + const name = (typeof rawName === "string" ? rawName : null) || shortenPubkey(event.pubkey); const avatar = profile?.picture; - const nip05 = profile?.nip05; + const nip05 = typeof profile?.nip05 === "string" ? profile.nip05 : null; const verified = useNip05Verified(event.pubkey, nip05); const time = event.created_at ? timeAgo(event.created_at) : ""; diff --git a/src/components/feed/NoteContent.tsx b/src/components/feed/NoteContent.tsx index dd5e776..19b5d72 100644 --- a/src/components/feed/NoteContent.tsx +++ b/src/components/feed/NoteContent.tsx @@ -122,7 +122,8 @@ function QuotePreview({ eventId }: { eventId: string }) { if (!event) return null; - const name = profile?.displayName || profile?.name || shortenPubkey(event.pubkey); + const rawName = profile?.displayName || profile?.name; + const name = (typeof rawName === "string" ? rawName : null) || shortenPubkey(event.pubkey); const preview = event.content.slice(0, 160) + (event.content.length > 160 ? "…" : ""); return ( diff --git a/src/components/feed/TextSegments.tsx b/src/components/feed/TextSegments.tsx index 043ddc5..1044db9 100644 --- a/src/components/feed/TextSegments.tsx +++ b/src/components/feed/TextSegments.tsx @@ -45,7 +45,8 @@ export function tryOpenNostrEntity(raw: string): boolean { export function MentionName({ pubkey, fallback }: { pubkey?: string; fallback: string }) { const profile = useProfile(pubkey ?? ""); if (!pubkey) return <>{fallback}; - const name = profile?.displayName || profile?.name; + const raw = profile?.displayName || profile?.name; + const name = typeof raw === "string" ? raw : null; return <>{name || fallback}; } @@ -65,7 +66,7 @@ export function renderTextSegments( segments.forEach((seg, i) => { switch (seg.type) { case "text": - elements.push({seg.value}); + elements.push({typeof seg.value === "string" ? seg.value : String(seg.value)}); break; case "link": elements.push( @@ -79,7 +80,7 @@ export function renderTextSegments( if (tryHandleUrlInternally(seg.value)) e.preventDefault(); }} > - {seg.display} + {typeof seg.display === "string" ? seg.display : String(seg.display)} ); break; @@ -91,8 +92,8 @@ export function renderTextSegments( onClick={(e) => { e.stopPropagation(); tryOpenNostrEntity(seg.value); }} > @{resolveMentions - ? - : seg.display} + ? + : String(seg.display ?? seg.value)} ); break; @@ -103,7 +104,7 @@ export function renderTextSegments( className="text-accent/80 cursor-pointer hover:text-accent" onClick={(e) => { e.stopPropagation(); openHashtag(seg.value); }} > - {seg.display} + {String(seg.display ?? seg.value)} ); break; diff --git a/src/components/follows/FollowsView.tsx b/src/components/follows/FollowsView.tsx index b8ee16c..1cdc496 100644 --- a/src/components/follows/FollowsView.tsx +++ b/src/components/follows/FollowsView.tsx @@ -6,7 +6,7 @@ import { useProfile } from "../../hooks/useProfile"; import { useNip05Verified } from "../../hooks/useNip05Verified"; import { fetchFollowers, ensureConnected } from "../../lib/nostr"; import { dbLoadFollowers, dbSaveFollowers } from "../../lib/db"; -import { shortenPubkey } from "../../lib/utils"; +import { shortenPubkey, profileName } from "../../lib/utils"; function FollowRow({ pubkey, @@ -16,9 +16,9 @@ function FollowRow({ followsYou?: boolean; }) { const profile = useProfile(pubkey); - const name = profile?.displayName || profile?.name || shortenPubkey(pubkey); - const avatar = profile?.picture; - const nip05 = profile?.nip05; + const name = profileName(profile, shortenPubkey(pubkey)); + const avatar = typeof profile?.picture === "string" ? profile.picture : undefined; + const nip05 = typeof profile?.nip05 === "string" ? profile.nip05 : undefined; const verified = useNip05Verified(pubkey, nip05); const { follows, follow, unfollow, pubkey: ownPubkey } = useUserStore(); diff --git a/src/components/profile/EditProfileForm.tsx b/src/components/profile/EditProfileForm.tsx index e9d0ce2..89ac3c8 100644 --- a/src/components/profile/EditProfileForm.tsx +++ b/src/components/profile/EditProfileForm.tsx @@ -7,14 +7,15 @@ import { Nip05Field } from "./Nip05Field"; export function EditProfileForm({ pubkey, onSaved }: { pubkey: string; onSaved: () => void }) { const { profile, fetchOwnProfile } = useUserStore(); - const [name, setName] = useState(profile?.name || ""); - const [displayName, setDisplayName] = useState(profile?.displayName || ""); - const [about, setAbout] = useState(profile?.about || ""); - const [picture, setPicture] = useState(profile?.picture || ""); - const [banner, setBanner] = useState(profile?.banner || ""); - const [website, setWebsite] = useState(profile?.website || ""); - const [nip05, setNip05] = useState(profile?.nip05 || ""); - const [lud16, setLud16] = useState(profile?.lud16 || ""); + const safeStr = (v: unknown) => (typeof v === "string" ? v : ""); + const [name, setName] = useState(safeStr(profile?.name)); + const [displayName, setDisplayName] = useState(safeStr(profile?.displayName)); + const [about, setAbout] = useState(safeStr(profile?.about)); + const [picture, setPicture] = useState(safeStr(profile?.picture)); + const [banner, setBanner] = useState(safeStr(profile?.banner)); + const [website, setWebsite] = useState(safeStr(profile?.website)); + const [nip05, setNip05] = useState(safeStr(profile?.nip05)); + const [lud16, setLud16] = useState(safeStr(profile?.lud16)); const [saving, setSaving] = useState(false); const [error, setError] = useState(null); const [saved, setSaved] = useState(false); diff --git a/src/components/profile/ProfileView.tsx b/src/components/profile/ProfileView.tsx index 603829c..778ddac 100644 --- a/src/components/profile/ProfileView.tsx +++ b/src/components/profile/ProfileView.tsx @@ -6,7 +6,7 @@ import { useMuteStore } from "../../stores/mute"; import { useProfile } from "../../hooks/useProfile"; import { useReputation } from "../../hooks/useReputation"; import { fetchUserNotesNIP65, fetchAuthorArticles, getNDK } from "../../lib/nostr"; -import { shortenPubkey } from "../../lib/utils"; +import { shortenPubkey, profileName } from "../../lib/utils"; import { NoteCard } from "../feed/NoteCard"; import { ArticleCard } from "../article/ArticleCard"; import { ZapModal } from "../zap/ZapModal"; @@ -17,7 +17,7 @@ import { ProfileMediaGallery } from "./ProfileMediaGallery"; function TopFollowerAvatar({ pubkey }: { pubkey: string }) { const profile = useProfile(pubkey); const { openProfile } = useUIStore(); - const name = profile?.displayName || profile?.name || pubkey.slice(0, 8) + "…"; + const name = profileName(profile, pubkey.slice(0, 8) + "…"); return (