Optimize rendering: memo, granular selectors, code splitting

- React.memo on NoteCard and ArticleCard to skip re-renders
- Granular Zustand selectors in Feed.tsx and NoteCard.tsx
- Lazy-load 19 views with React.lazy + Suspense boundary
This commit is contained in:
Jure
2026-04-02 18:03:19 +02:00
parent fd26a743d7
commit ae5b9a444c
4 changed files with 77 additions and 49 deletions

View File

@@ -1,29 +1,31 @@
import { useState, useEffect } from "react";
import { useState, useEffect, lazy, Suspense } from "react";
import { openUrl } from "@tauri-apps/plugin-opener";
import { Sidebar } from "./components/sidebar/Sidebar";
import { Feed } from "./components/feed/Feed";
import { SearchView } from "./components/search/SearchView";
import { RelaysView } from "./components/shared/RelaysView";
import { SettingsView } from "./components/shared/SettingsView";
import { ProfileView } from "./components/profile/ProfileView";
import { ThreadView } from "./components/thread/ThreadView";
import { ArticleEditor } from "./components/article/ArticleEditor";
import { ArticleView } from "./components/article/ArticleView";
import { ArticleFeed } from "./components/article/ArticleFeed";
import { MediaFeed } from "./components/media/MediaFeed";
import { OnboardingFlow } from "./components/onboarding/OnboardingFlow";
import { AboutView } from "./components/shared/AboutView";
import { ZapHistoryView } from "./components/zap/ZapHistoryView";
import { DMView } from "./components/dm/DMView";
import { NotificationsView } from "./components/notifications/NotificationsView";
import { BookmarkView } from "./components/bookmark/BookmarkView";
import { HashtagFeed } from "./components/feed/HashtagFeed";
import { PodcastsView } from "./components/podcast/PodcastsView";
import { FollowsView } from "./components/follows/FollowsView";
import { PodcastPlayerBar } from "./components/podcast/PodcastPlayerBar";
import { ToastContainer } from "./components/shared/ToastContainer";
import { DebugPanel } from "./components/shared/DebugPanel";
import { HelpModal } from "./components/shared/HelpModal";
// Lazy-loaded views — only fetched when navigated to
const SearchView = lazy(() => import("./components/search/SearchView").then(m => ({ default: m.SearchView })));
const RelaysView = lazy(() => import("./components/shared/RelaysView").then(m => ({ default: m.RelaysView })));
const SettingsView = lazy(() => import("./components/shared/SettingsView").then(m => ({ default: m.SettingsView })));
const ProfileView = lazy(() => import("./components/profile/ProfileView").then(m => ({ default: m.ProfileView })));
const ThreadView = lazy(() => import("./components/thread/ThreadView").then(m => ({ default: m.ThreadView })));
const ArticleEditor = lazy(() => import("./components/article/ArticleEditor").then(m => ({ default: m.ArticleEditor })));
const ArticleView = lazy(() => import("./components/article/ArticleView").then(m => ({ default: m.ArticleView })));
const ArticleFeed = lazy(() => import("./components/article/ArticleFeed").then(m => ({ default: m.ArticleFeed })));
const MediaFeed = lazy(() => import("./components/media/MediaFeed").then(m => ({ default: m.MediaFeed })));
const AboutView = lazy(() => import("./components/shared/AboutView").then(m => ({ default: m.AboutView })));
const ZapHistoryView = lazy(() => import("./components/zap/ZapHistoryView").then(m => ({ default: m.ZapHistoryView })));
const DMView = lazy(() => import("./components/dm/DMView").then(m => ({ default: m.DMView })));
const NotificationsView = lazy(() => import("./components/notifications/NotificationsView").then(m => ({ default: m.NotificationsView })));
const BookmarkView = lazy(() => import("./components/bookmark/BookmarkView").then(m => ({ default: m.BookmarkView })));
const HashtagFeed = lazy(() => import("./components/feed/HashtagFeed").then(m => ({ default: m.HashtagFeed })));
const PodcastsView = lazy(() => import("./components/podcast/PodcastsView").then(m => ({ default: m.PodcastsView })));
const FollowsView = lazy(() => import("./components/follows/FollowsView").then(m => ({ default: m.FollowsView })));
const DebugPanel = lazy(() => import("./components/shared/DebugPanel").then(m => ({ default: m.DebugPanel })));
const HelpModal = lazy(() => import("./components/shared/HelpModal").then(m => ({ default: m.HelpModal })));
import { useUIStore } from "./stores/ui";
import { getTheme, applyTheme } from "./lib/themes";
import { useUpdater } from "./hooks/useUpdater";
@@ -111,23 +113,25 @@ function App() {
<div className={currentView === "feed" ? "contents" : "hidden"}>
<Feed />
</div>
{currentView === "search" && <SearchView />}
{currentView === "relays" && <RelaysView />}
{currentView === "settings" && <SettingsView />}
{currentView === "profile" && <ProfileView />}
{currentView === "thread" && <ThreadView />}
{currentView === "articles" && <ArticleFeed />}
{currentView === "media" && <MediaFeed />}
{currentView === "article-editor" && <ArticleEditor />}
{currentView === "article" && <ArticleView />}
{currentView === "about" && <AboutView />}
{currentView === "zaps" && <ZapHistoryView />}
{currentView === "dm" && <DMView />}
{currentView === "notifications" && <NotificationsView />}
{currentView === "bookmarks" && <BookmarkView />}
{currentView === "hashtag" && <HashtagFeed />}
{currentView === "podcasts" && <PodcastsView />}
{currentView === "follows" && <FollowsView />}
<Suspense fallback={null}>
{currentView === "search" && <SearchView />}
{currentView === "relays" && <RelaysView />}
{currentView === "settings" && <SettingsView />}
{currentView === "profile" && <ProfileView />}
{currentView === "thread" && <ThreadView />}
{currentView === "articles" && <ArticleFeed />}
{currentView === "media" && <MediaFeed />}
{currentView === "article-editor" && <ArticleEditor />}
{currentView === "article" && <ArticleView />}
{currentView === "about" && <AboutView />}
{currentView === "zaps" && <ZapHistoryView />}
{currentView === "dm" && <DMView />}
{currentView === "notifications" && <NotificationsView />}
{currentView === "bookmarks" && <BookmarkView />}
{currentView === "hashtag" && <HashtagFeed />}
{currentView === "podcasts" && <PodcastsView />}
{currentView === "follows" && <FollowsView />}
</Suspense>
</main>
</div>
<PodcastPlayerBar />

View File

@@ -1,3 +1,4 @@
import { memo } from "react";
import { NDKEvent, nip19 } from "@nostr-dev-kit/ndk";
import { useProfile } from "../../hooks/useProfile";
import { useUIStore } from "../../stores/ui";
@@ -21,7 +22,7 @@ function buildNaddr(event: NDKEvent): string {
});
}
export function ArticleCard({ event }: { event: NDKEvent }) {
export const ArticleCard = memo(function ArticleCard({ event }: { event: NDKEvent }) {
const { openArticle, openProfile } = useUIStore();
const profile = useProfile(event.pubkey);
@@ -117,4 +118,4 @@ export function ArticleCard({ event }: { event: NDKEvent }) {
</div>
</div>
);
}
});

View File

@@ -24,10 +24,25 @@ function timeAgo(ts: number): string {
}
export function Feed() {
const { notes, loading, error, connect, loadCachedFeed, loadFeed, trendingNotes, trendingLoading, loadTrendingFeed, focusedNoteIndex, lastUpdated } = useFeedStore();
const { loggedIn, follows } = useUserStore();
const { mutedPubkeys, contentMatchesMutedKeyword } = useMuteStore();
const { feedTab: tab, setFeedTab: setTab, feedLanguageFilter, setFeedLanguageFilter } = useUIStore();
const notes = useFeedStore((s) => s.notes);
const loading = useFeedStore((s) => s.loading);
const error = useFeedStore((s) => s.error);
const connect = useFeedStore((s) => s.connect);
const loadCachedFeed = useFeedStore((s) => s.loadCachedFeed);
const loadFeed = useFeedStore((s) => s.loadFeed);
const trendingNotes = useFeedStore((s) => s.trendingNotes);
const trendingLoading = useFeedStore((s) => s.trendingLoading);
const loadTrendingFeed = useFeedStore((s) => s.loadTrendingFeed);
const focusedNoteIndex = useFeedStore((s) => s.focusedNoteIndex);
const lastUpdated = useFeedStore((s) => s.lastUpdated);
const loggedIn = useUserStore((s) => s.loggedIn);
const follows = useUserStore((s) => s.follows);
const mutedPubkeys = useMuteStore((s) => s.mutedPubkeys);
const contentMatchesMutedKeyword = useMuteStore((s) => s.contentMatchesMutedKeyword);
const tab = useUIStore((s) => s.feedTab);
const setTab = useUIStore((s) => s.setFeedTab);
const feedLanguageFilter = useUIStore((s) => s.feedLanguageFilter);
const setFeedLanguageFilter = useUIStore((s) => s.setFeedLanguageFilter);
const [followNotes, setFollowNotes] = useState<NDKEvent[]>([]);
const [followLoading, setFollowLoading] = useState(false);
const [, setTick] = useState(0);

View File

@@ -1,4 +1,4 @@
import { useState, useRef, useEffect } from "react";
import { useState, useRef, useEffect, memo } from "react";
import { NDKEvent } from "@nostr-dev-kit/ndk";
import { useProfile } from "../../hooks/useProfile";
import { useNip05Verified } from "../../hooks/useNip05Verified";
@@ -25,7 +25,7 @@ function ParentAuthorName({ pubkey }: { pubkey: string }) {
return <span className="text-accent">@{name}</span>;
}
export function NoteCard({ event, focused, onReplyInThread }: NoteCardProps) {
export const NoteCard = memo(function NoteCard({ event, focused, onReplyInThread }: NoteCardProps) {
const profile = useProfile(event.pubkey);
const rawName = profile?.displayName || profile?.name;
const name = (typeof rawName === "string" ? rawName : null) || shortenPubkey(event.pubkey);
@@ -34,10 +34,18 @@ export function NoteCard({ event, focused, onReplyInThread }: NoteCardProps) {
const verified = useNip05Verified(event.pubkey, nip05);
const time = event.created_at ? timeAgo(event.created_at) : "";
const { loggedIn, pubkey: ownPubkey, follows, follow, unfollow } = useUserStore();
const { mutedPubkeys, mute, unmute } = useMuteStore();
const loggedIn = useUserStore((s) => s.loggedIn);
const ownPubkey = useUserStore((s) => s.pubkey);
const follows = useUserStore((s) => s.follows);
const follow = useUserStore((s) => s.follow);
const unfollow = useUserStore((s) => s.unfollow);
const mutedPubkeys = useMuteStore((s) => s.mutedPubkeys);
const mute = useMuteStore((s) => s.mute);
const unmute = useMuteStore((s) => s.unmute);
const isMuted = mutedPubkeys.includes(event.pubkey);
const { openProfile, openThread, currentView } = useUIStore();
const openProfile = useUIStore((s) => s.openProfile);
const openThread = useUIStore((s) => s.openThread);
const currentView = useUIStore((s) => s.currentView);
const parentEventId = getParentEventId(event);
// The immediate parent author is typically the last p tag (NIP-10 ordering mirrors e tags).
@@ -194,4 +202,4 @@ export function NoteCard({ event, focused, onReplyInThread }: NoteCardProps) {
</div>
</article>
);
}
});