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.
This commit is contained in:
Jure
2026-04-01 12:09:57 +02:00
parent 5a9d387923
commit c1029327e7
16 changed files with 92 additions and 52 deletions

View File

@@ -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;

View File

@@ -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;

View File

@@ -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<NDKEvent[]>([]);
const [loading, setLoading] = useState(true);

View File

@@ -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<HTMLTextAreaElement>(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(() => {

View File

@@ -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!);

View File

@@ -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 <span className="text-accent">@{name}</span>;
}
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) : "";

View File

@@ -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 (

View File

@@ -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(<span key={i}>{seg.value}</span>);
elements.push(<span key={i}>{typeof seg.value === "string" ? seg.value : String(seg.value)}</span>);
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)}
</a>
);
break;
@@ -91,8 +92,8 @@ export function renderTextSegments(
onClick={(e) => { e.stopPropagation(); tryOpenNostrEntity(seg.value); }}
>
@{resolveMentions
? <MentionName pubkey={seg.mentionPubkey} fallback={seg.display ?? seg.value.slice(0, 12) + "…"} />
: seg.display}
? <MentionName pubkey={seg.mentionPubkey} fallback={String(seg.display ?? seg.value).slice(0, 12) + "…"} />
: String(seg.display ?? seg.value)}
</span>
);
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)}
</span>
);
break;

View File

@@ -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();

View File

@@ -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<string | null>(null);
const [saved, setSaved] = useState(false);

View File

@@ -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 (
<button
@@ -85,12 +85,12 @@ export function ProfileView() {
}
};
const name = profile?.displayName || profile?.name || shortenPubkey(pubkey);
const avatar = profile?.picture;
const about = profile?.about;
const nip05 = profile?.nip05;
const website = profile?.website;
const lud16 = profile?.lud16;
const name = profileName(profile, shortenPubkey(pubkey));
const avatar = typeof profile?.picture === "string" ? profile.picture : undefined;
const about = typeof profile?.about === "string" ? profile.about : undefined;
const nip05 = typeof profile?.nip05 === "string" ? profile.nip05 : undefined;
const website = typeof profile?.website === "string" ? profile.website : undefined;
const lud16 = typeof profile?.lud16 === "string" ? profile.lud16 : undefined;
useEffect(() => {
let cancelled = false;

View File

@@ -1,12 +1,12 @@
import { NDKEvent } from "@nostr-dev-kit/ndk";
import { useProfile } from "../../hooks/useProfile";
import { useUIStore } from "../../stores/ui";
import { shortenPubkey, timeAgo } from "../../lib/utils";
import { shortenPubkey, timeAgo, profileName } from "../../lib/utils";
function AncestorCard({ event }: { event: NDKEvent }) {
const profile = useProfile(event.pubkey);
const name = profile?.displayName || profile?.name || shortenPubkey(event.pubkey);
const avatar = profile?.picture;
const name = profileName(profile, shortenPubkey(event.pubkey));
const avatar = typeof profile?.picture === "string" ? profile.picture : undefined;
const time = event.created_at ? timeAgo(event.created_at) : "";
const { openThread } = useUIStore();

View File

@@ -4,7 +4,7 @@ import type { ThreadNode as ThreadNodeType } from "../../lib/threadTree";
import { NoteCard } from "../feed/NoteCard";
import { publishReply } from "../../lib/nostr";
import { useProfile } from "../../hooks/useProfile";
import { shortenPubkey } from "../../lib/utils";
import { shortenPubkey, profileName } from "../../lib/utils";
import { EmojiPicker } from "../shared/EmojiPicker";
import { useAutoResize } from "../../hooks/useAutoResize";
@@ -26,7 +26,7 @@ function InlineThreadReply({ replyTo, rootEvent, onPublished }: {
onPublished: (reply: NDKEvent) => void;
}) {
const profile = useProfile(replyTo.pubkey);
const name = profile?.displayName || profile?.name || shortenPubkey(replyTo.pubkey);
const name = profileName(profile, shortenPubkey(replyTo.pubkey));
const [text, setText] = useState("");
const [replying, setReplying] = useState(false);
const [sent, setSent] = useState(false);

View File

@@ -4,7 +4,7 @@ import { useUserStore } from "../../stores/user";
import { useUIStore } from "../../stores/ui";
import { fetchZapsReceived, fetchZapsSent, fetchNoteById } from "../../lib/nostr";
import { useProfile } from "../../hooks/useProfile";
import { timeAgo, shortenPubkey } from "../../lib/utils";
import { timeAgo, shortenPubkey, profileName } from "../../lib/utils";
// ── Parsing helpers ──────────────────────────────────────────────────────────
@@ -53,9 +53,9 @@ function ZapRow({
const { openProfile, openThread } = useUIStore();
const profile = useProfile(pubkey ?? "");
const name = pubkey
? profile?.displayName || profile?.name || shortenPubkey(pubkey)
? profileName(profile, shortenPubkey(pubkey))
: "anonymous";
const avatar = profile?.picture;
const avatar = typeof profile?.picture === "string" ? profile.picture : undefined;
const [notePreview, setNotePreview] = useState<string | null>(null);
const [noteEvent, setNoteEvent] = useState<NDKEvent | null>(null);
const [loadingNote, setLoadingNote] = useState(false);

View File

@@ -11,3 +11,9 @@ export function timeAgo(timestamp: number): string {
export function shortenPubkey(pubkey: string): string {
return pubkey.slice(0, 8) + "…" + pubkey.slice(-4);
}
/** Safely extract display name from a Nostr profile (handles non-string values from malformed profiles). */
export function profileName(profile: any, fallback: string): string {
const raw = profile?.displayName || profile?.name;
return (typeof raw === "string" ? raw : null) || fallback;
}

View File

@@ -1,15 +1,42 @@
import "./lib/tauri-dev-mock"; // must be first — mocks Tauri invoke() in browser dev mode
import { StrictMode } from "react";
import { StrictMode, Component, type ReactNode } from "react";
import { createRoot } from "react-dom/client";
import App from "./App";
import "./index.css";
import { useUserStore } from "./stores/user";
// Error boundary to catch React error #31 and show details instead of blank screen
class ErrorBoundary extends Component<{ children: ReactNode }, { error: Error | null }> {
state: { error: Error | null } = { error: null };
static getDerivedStateFromError(error: Error) { return { error }; }
componentDidCatch(error: Error, info: React.ErrorInfo) {
console.error("[Vega] React error boundary caught:", error, info.componentStack);
}
render() {
if (this.state.error) {
return (
<div style={{ padding: 20, color: "#ff6b6b", fontFamily: "monospace", fontSize: 13 }}>
<h2>Vega crashed</h2>
<pre style={{ whiteSpace: "pre-wrap", wordBreak: "break-all" }}>
{this.state.error.message}
</pre>
<pre style={{ whiteSpace: "pre-wrap", wordBreak: "break-all", color: "#aaa", marginTop: 10 }}>
{this.state.error.stack}
</pre>
</div>
);
}
return this.props.children;
}
}
// Restore session — pubkey (read-only) or nsec via OS keychain
useUserStore.getState().restoreSession();
createRoot(document.getElementById("root") as HTMLElement).render(
<StrictMode>
<App />
<ErrorBoundary>
<App />
</ErrorBoundary>
</StrictMode>,
);