Fix feed OOM: lazy image loading, inView gating, WebKit memory tuning

- NoteContent: remove ImageGrid from inline mode — images now only load
  when note is inView (via mediaOnly), stopping runaway scrolling leak
- NoteCard: content-visibility:auto to skip layout/paint for off-screen
  cards; inView-gated media, NoteActions, NIP-05 verification
- useInView: new IntersectionObserver hook with 300px rootMargin
- useProfile: MAX_PROFILE_CONCURRENT=8 throttle with fetch queue
- useReplyCount/useZapCount/useReactions: enabled param, throttled queues
- feed.ts: MAX_FEED_SIZE 200→30, live sub disabled (pendingNotes pattern),
  250ms batch debounce on live events
- core.ts: MAX_CONCURRENT_FETCHES=25 global NDK cap, fetchWithTimeout
  uses subscribe+stop instead of fetchEvents (no zombie subscriptions)
- lib.rs: HardwareAccelerationPolicy::Never + CacheModel::DocumentViewer
- main.rs: WEBKIT_DISABLE_COMPOSITING_MODE=1 for Linux
- relay/db.rs: TTL eviction + 5000 event cap
- feedDiagnostics.ts: file-flushing diag log survives crashes
This commit is contained in:
Jure
2026-04-15 20:36:14 +02:00
parent 018ee0e0f3
commit 0894389fe0
20 changed files with 540 additions and 105 deletions
+14 -5
View File
@@ -4,7 +4,7 @@ import { useUserStore } from "../../stores/user";
import { useMuteStore } from "../../stores/mute";
import { useUIStore } from "../../stores/ui";
import { useWoTStore } from "../../stores/wot";
import { fetchFollowFeed, getNDK, ensureConnected, batchFetchProfileAges } from "../../lib/nostr";
import { fetchFollowFeed, getNDK, ensureConnected } from "../../lib/nostr";
import { diagWrapFetch, logDiag } from "../../lib/feedDiagnostics";
import { detectScript, getEventLanguageTag, FILTER_SCRIPTS } from "../../lib/language";
import { NoteCard } from "./NoteCard";
@@ -26,6 +26,8 @@ function timeAgo(ts: number): string {
export function Feed() {
const notes = useFeedStore((s) => s.notes);
const pendingNotes = useFeedStore((s) => s.pendingNotes);
const flushPendingNotes = useFeedStore((s) => s.flushPendingNotes);
const loading = useFeedStore((s) => s.loading);
const error = useFeedStore((s) => s.error);
const connect = useFeedStore((s) => s.connect);
@@ -63,10 +65,7 @@ export function Feed() {
useEffect(() => {
// Show cached notes immediately, then fetch fresh ones once connected
loadCachedFeed();
connect().then(() => loadFeed().then(() => {
const pubkeys = [...new Set(useFeedStore.getState().notes.map((e) => e.pubkey))];
batchFetchProfileAges(pubkeys);
}));
connect().then(() => loadFeed());
}, []);
@@ -265,6 +264,16 @@ export function Feed() {
</div>
)}
{/* New notes banner — only shown on global tab */}
{tab === "global" && pendingNotes.length > 0 && (
<button
onClick={flushPendingNotes}
className="w-full py-2 text-[12px] text-accent border-b border-accent/20 bg-accent/5 hover:bg-accent/10 transition-colors"
>
{pendingNotes.length} new {pendingNotes.length === 1 ? "note" : "notes"} click to load
</button>
)}
{filteredNotes.map((event, index) =>
event.kind === 30023 ? (
<ArticleCard key={event.id} event={event} />
+9 -8
View File
@@ -17,9 +17,10 @@ interface NoteActionsProps {
event: NDKEvent;
onReplyToggle: () => void;
showReply: boolean;
enabled?: boolean;
}
export function NoteActions({ event, onReplyToggle, showReply }: NoteActionsProps) {
export function NoteActions({ event, onReplyToggle, showReply, enabled = true }: NoteActionsProps) {
const profile = useProfile(event.pubkey);
const name = profileName(profile, event.pubkey.slice(0, 8) + "…");
const avatar = typeof profile?.picture === "string" ? profile.picture : undefined;
@@ -27,12 +28,12 @@ export function NoteActions({ event, onReplyToggle, showReply }: NoteActionsProp
const { bookmarkedIds, addBookmark, removeBookmark } = useBookmarkStore();
const isBookmarked = bookmarkedIds.includes(event.id!);
const [reactionsData, addReaction] = useReactions(event.id);
const [reactionsData, addReaction] = useReactions(event.id, enabled);
const [reacting, setReacting] = useState(false);
const [showEmojiPicker, setShowEmojiPicker] = useState(false);
const [replyCount] = useReplyCount(event.id);
const [replyCount] = useReplyCount(event.id, enabled);
const [copied, setCopied] = useState(false);
const zapData = useZapCount(event.id);
const zapData = useZapCount(event.id, enabled);
const [showZap, setShowZap] = useState(false);
const [showQuote, setShowQuote] = useState(false);
const [reposting, setReposting] = useState(false);
@@ -227,10 +228,10 @@ export function NoteActions({ event, onReplyToggle, showReply }: NoteActionsProp
);
}
export function LoggedOutStats({ event }: { event: NDKEvent }) {
const [reactionsData] = useReactions(event.id);
const [replyCount] = useReplyCount(event.id);
const zapData = useZapCount(event.id);
export function LoggedOutStats({ event, enabled = true }: { event: NDKEvent; enabled?: boolean }) {
const [reactionsData] = useReactions(event.id, enabled);
const [replyCount] = useReplyCount(event.id, enabled);
const zapData = useZapCount(event.id, enabled);
const [copied, setCopied] = useState(false);
const handleShare = async () => {
+11 -5
View File
@@ -2,6 +2,7 @@ import { useState, useRef, useEffect, memo } from "react";
import { NDKEvent } from "@nostr-dev-kit/ndk";
import { useProfile } from "../../hooks/useProfile";
import { useNip05Verified } from "../../hooks/useNip05Verified";
import { useInView } from "../../hooks/useInView";
import { useUserStore } from "../../stores/user";
import { useMuteStore } from "../../stores/mute";
import { useUIStore } from "../../stores/ui";
@@ -27,12 +28,15 @@ function ParentAuthorName({ pubkey }: { pubkey: string }) {
}
export const NoteCard = memo(function NoteCard({ event, focused, onReplyInThread }: NoteCardProps) {
const cardRef = useRef<HTMLElement>(null);
const inView = useInView(cardRef);
const profile = useProfile(event.pubkey);
const rawName = profile?.displayName || profile?.name;
const name = (typeof rawName === "string" ? rawName : null) || shortenPubkey(event.pubkey);
const avatar = profile?.picture;
const nip05 = typeof profile?.nip05 === "string" ? profile.nip05 : null;
const verified = useNip05Verified(event.pubkey, nip05);
const verified = useNip05Verified(event.pubkey, nip05, inView);
const time = event.created_at ? timeAgo(event.created_at) : "";
const profileCreatedAt = getProfileAge(event.pubkey);
const isNewAccount = profileCreatedAt !== null && (Date.now() / 1000 - profileCreatedAt) < 60 * 24 * 3600;
@@ -56,7 +60,6 @@ export const NoteCard = memo(function NoteCard({ event, focused, onReplyInThread
const pTags = event.tags.filter((t) => t[0] === "p");
const parentAuthorPubkey = pTags.length > 0 ? pTags[pTags.length - 1][1] : null;
const cardRef = useRef<HTMLElement>(null);
useEffect(() => {
if (focused) cardRef.current?.scrollIntoView({ behavior: "smooth", block: "nearest" });
}, [focused]);
@@ -68,7 +71,7 @@ export const NoteCard = memo(function NoteCard({ event, focused, onReplyInThread
<article
ref={cardRef}
data-note-id={event.id}
className={`border-b border-border px-4 py-3 hover:bg-bg-hover transition-colors cursor-pointer group/card${focused ? " bg-accent/10 border-l-2 border-l-accent" : ""}`}
className={`border-b border-border px-4 py-3 hover:bg-bg-hover transition-colors cursor-pointer group/card [content-visibility:auto] [contain-intrinsic-size:auto_120px]${focused ? " bg-accent/10 border-l-2 border-l-accent" : ""}`}
onClick={(e) => {
// Don't navigate if clicking on interactive elements
const target = e.target as HTMLElement;
@@ -83,6 +86,8 @@ export const NoteCard = memo(function NoteCard({ event, focused, onReplyInThread
<img
src={avatar}
alt={`${name}'s avatar`}
width={36}
height={36}
className="w-9 h-9 rounded-sm object-cover bg-bg-raised ring-1 ring-transparent hover:ring-accent/40 transition-all"
loading="lazy"
onError={(e) => {
@@ -183,7 +188,7 @@ export const NoteCard = memo(function NoteCard({ event, focused, onReplyInThread
<div>
<NoteContent content={event.content} inline />
</div>
<NoteContent content={event.content} mediaOnly />
{inView && <NoteContent content={event.content} mediaOnly />}
{/* Poll options — kind 1068 */}
{event.kind === 1068 && <PollWidget event={event} />}
@@ -200,11 +205,12 @@ export const NoteCard = memo(function NoteCard({ event, focused, onReplyInThread
}
}}
showReply={showReply && !onReplyInThread}
enabled={inView}
/>
)}
{/* Stats visible when logged out */}
{!loggedIn && <LoggedOutStats event={event} />}
{!loggedIn && <LoggedOutStats event={event} enabled={inView} />}
{/* Inline reply box */}
{showReply && <InlineReplyBox event={event} name={name} />}
+16 -17
View File
@@ -164,13 +164,24 @@ export function NoteContent({ content, inline, mediaOnly }: NoteContentProps) {
const quoteIds: string[] = segments.filter((s) => s.type === "quote").map((s) => s.value);
const [lightboxIndex, setLightboxIndex] = useState<number | null>(null);
// --- Inline text + images (safe inside clickable wrapper) ---
// --- Inline text only (no images — images go in mediaOnly to allow inView gating) ---
if (inline) {
return (
<div>
<div className="note-content text-text text-[13px] break-words whitespace-pre-wrap leading-relaxed">
{renderTextSegments(segments, openHashtag, { resolveMentions: true })}
</div>
<div className="note-content text-text text-[13px] break-words whitespace-pre-wrap leading-relaxed">
{renderTextSegments(segments, openHashtag, { resolveMentions: true })}
</div>
);
}
// --- Media blocks only (rendered OUTSIDE the clickable wrapper, gated by inView) ---
// Images are included here so they only load when the note is near the viewport.
if (mediaOnly) {
const hasMedia = images.length > 0 || videos.length > 0 || audios.length > 0 || youtubes.length > 0
|| vimeos.length > 0 || spotifys.length > 0 || tidals.length > 0 || fountains.length > 0 || quoteIds.length > 0;
if (!hasMedia) return null;
return (
<div onClick={(e) => e.stopPropagation()}>
<ImageGrid images={images} onImageClick={setLightboxIndex} />
{lightboxIndex !== null && (
<ImageLightbox
@@ -180,18 +191,6 @@ export function NoteContent({ content, inline, mediaOnly }: NoteContentProps) {
onNavigate={setLightboxIndex}
/>
)}
</div>
);
}
// --- Media blocks only (rendered OUTSIDE the clickable wrapper) ---
if (mediaOnly) {
const hasMedia = videos.length > 0 || audios.length > 0 || youtubes.length > 0
|| vimeos.length > 0 || spotifys.length > 0 || tidals.length > 0 || fountains.length > 0 || quoteIds.length > 0;
if (!hasMedia) return null;
return (
<div onClick={(e) => e.stopPropagation()}>
<VideoBlock sources={videos} />
<AudioBlock sources={audios} />
{youtubes.map((seg, i) => <YouTubeCard key={`yt-${i}`} seg={seg} />)}