fix: feed flicker — fixed-height quote box + content-aware row estimate

Two remaining causes of upward-scroll flicker in the virtualized feed:

- QuotePreview rendered nothing until fetchNoteById resolved, then popped
  in a box — a 70-200px late growth that desynced the virtualizer every
  time a quote note mounted. It now renders a fixed 80px (h-20) box,
  reserved whether or not the quoted note has loaded; a 2-line clamp keeps
  resolved content inside it.

- estimateSize was a flat 140px, so every row snapped from 140 to its real
  height (96-1660px) on first measurement. New estimateNoteHeight predicts
  height from note content (text, media boxes, quote box) so the
  first-measure correction is small enough to be imperceptible.

Remaining: a <=25px transient on some notes' first few views (a flex-wrap
row reflowing as async content lands); self-heals as caches warm.
This commit is contained in:
Jure
2026-05-19 17:50:37 +02:00
parent 30f050119b
commit 7b6f67f42e
3 changed files with 117 additions and 15 deletions
+10 -1
View File
@@ -8,6 +8,7 @@ import { useWoTStore } from "../../stores/wot";
import { fetchFollowFeed, getNDK, ensureConnected } from "../../lib/nostr";
import { diagWrapFetch, logDiag } from "../../lib/feedDiagnostics";
import { detectScript, getEventLanguageTag, FILTER_SCRIPTS } from "../../lib/language";
import { estimateNoteHeight } from "../../lib/feedEstimate";
import { NoteCard } from "./NoteCard";
import { ArticleCard } from "../article/ArticleCard";
import { ComposeBox } from "./ComposeBox";
@@ -147,7 +148,15 @@ export function Feed() {
const virtualizer = useVirtualizer({
count: filteredNotes.length,
getScrollElement: () => scrollRef.current,
estimateSize: () => 140,
// Content-aware estimate: a flat estimate makes every row snap to its real
// height on first measurement → upward-scroll flicker. estimateNoteHeight
// predicts the height from the note's content so the snap is negligible.
estimateSize: (index) => {
const e = filteredNotes[index];
if (!e) return 140;
// content column = scroll width minus card padding (32) + avatar col (48)
return estimateNoteHeight(e, (scrollRef.current?.clientWidth ?? 600) - 80);
},
overscan: 6,
// Key measurements by note id, not list index. The feed list mutates order
// constantly — new notes prepend (flushPendingNotes), the WoT filter removes
+21 -14
View File
@@ -93,25 +93,32 @@ function QuotePreview({ eventId }: { eventId: string }) {
fetchNoteById(eventId).then(setEvent);
}, [eventId]);
if (!event) return null;
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 ? "…" : "");
const name = (typeof rawName === "string" ? rawName : null) || (event ? shortenPubkey(event.pubkey) : "");
const preview = event ? event.content.slice(0, 160) + (event.content.length > 160 ? "…" : "") : "";
// Fixed-height box, reserved whether or not the quoted note has resolved.
// QuotePreview used to render null until fetchNoteById resolved, then pop in
// a box — that late growth (~70-200px) desynced the virtualizer and was the
// main cause of upward-scroll flicker. The box height never changes now; the
// 2-line clamp keeps resolved content inside the reserved space.
return (
<div
className="mt-2 border border-border bg-bg-raised px-3 py-2 cursor-pointer hover:bg-bg-hover transition-colors"
onClick={(e) => { e.stopPropagation(); openThread(event, currentView as "feed" | "profile"); }}
className={`mt-2 h-20 border border-border bg-bg-raised px-3 py-2 overflow-hidden transition-colors ${event ? "cursor-pointer hover:bg-bg-hover" : ""}`}
onClick={event ? (e) => { e.stopPropagation(); openThread(event, currentView as "feed" | "profile"); } : undefined}
>
<div className="flex items-center gap-2 mb-1">
{profile?.picture && (
<img src={profile.picture} alt={`${name}'s avatar`} className="w-4 h-4 rounded-sm object-cover shrink-0"
onError={(e) => { (e.target as HTMLImageElement).style.display = "none"; }} />
)}
<span className="text-text-muted text-[11px] font-medium truncate">{name}</span>
</div>
<p className="text-text-dim text-[11px] leading-relaxed whitespace-pre-wrap break-words">{preview}</p>
{event && (
<>
<div className="flex items-center gap-2 mb-1">
{profile?.picture && (
<img src={profile.picture} alt={`${name}'s avatar`} className="w-4 h-4 rounded-md object-cover shrink-0"
onError={(e) => { (e.target as HTMLImageElement).style.display = "none"; }} />
)}
<span className="text-text-muted text-[11px] font-medium truncate">{name}</span>
</div>
<p className="text-text-dim text-[11px] leading-relaxed break-words line-clamp-2">{preview}</p>
</>
)}
</div>
);
}
+86
View File
@@ -0,0 +1,86 @@
import { NDKEvent } from "@nostr-dev-kit/ndk";
import { parseContent } from "./parsing";
/**
* Estimated rendered height (px) of a feed row — used as the virtualizer's
* `estimateSize`.
*
* A flat estimate makes every row snap from the guess to its real height the
* first time it's measured; on upward scroll each snap nudges the rows below
* it, producing a subtle per-row flicker. A content-aware estimate keeps that
* correction small enough to be imperceptible.
*
* Exactness is not required — within ~10-15% of the real height is plenty.
* Constants are matched to NoteCard / NoteContent layout.
*/
const cache = new Map<string, number>();
const BASE = 96; // avatar/name/time row + action row + vertical padding
const LINE = 21; // one line of 13px / leading-relaxed body text
const QUOTE = 88; // QuotePreview h-20 box + mt-2
const LINK_CARD = 70; // youtube / vimeo / spotify / tidal / fountain row + mt-2
const AUDIO = 64; // AudioBlock row + mt-2
const MEDIA_GAP = 8; // mt-2 above a media block
const ARTICLE = 132; // ArticleCard is near-uniform height
function textHeight(text: string, charsPerLine: number): number {
if (!text.trim()) return 0;
let lines = 0;
for (const para of text.split("\n")) {
lines += Math.max(1, Math.ceil(para.length / charsPerLine));
}
return lines * LINE;
}
/**
* @param mediaWidth width (px) available to the note's content column —
* scroll-container width minus card padding and the avatar column.
*/
export function estimateNoteHeight(event: NDKEvent, mediaWidth: number): number {
if (event.kind === 30023) return ARTICLE;
const id = event.id;
if (id && cache.has(id)) return cache.get(id)!;
const charsPerLine = Math.max(20, Math.floor(mediaWidth / 7));
const segments = parseContent(event.content);
let h = BASE;
let images = 0;
let videos = 0;
let inlineText = "";
for (const s of segments) {
switch (s.type) {
case "image": images++; break;
case "video": videos++; break;
case "audio": h += AUDIO; break;
case "youtube":
case "vimeo":
case "spotify":
case "tidal":
case "fountain": h += LINK_CARD; break;
case "quote": h += QUOTE; break;
default: inlineText += (s.value ?? "") + " ";
}
}
h += textHeight(inlineText, charsPerLine);
// Image grid — boxes use aspect-[4/3]; see ImageGrid in NoteContent.
if (images === 1) {
h += mediaWidth * 0.75 + MEDIA_GAP;
} else if (images === 2) {
h += ((mediaWidth - 4) / 2) * 0.75 + MEDIA_GAP; // one row of two
} else if (images >= 3) {
h += (mediaWidth - 4) * 0.75 + 4 + MEDIA_GAP; // two rows
}
// Videos — aspect-video (16:9).
h += videos * ((mediaWidth * 9) / 16 + MEDIA_GAP);
const result = Math.round(h);
if (id) cache.set(id, result);
return result;
}