fix: feed virtualization — overlapping cards and phantom gaps

Three compounding causes in the v0.12.17 virtualized feed:

- Measurement cache was keyed by list index; the feed reorders (refresh
  merge, WoT filter, tab switch, cached->fresh swap) so cached heights
  reattached to the wrong note. Fixed with getItemKey by note id.
- <video> and single <img> had no reserved height and resized after
  metadata/image load — after the virtualizer measured the row. They
  now sit in fixed aspect-ratio boxes.
- The custom measureElement returned the 140px estimate for not-yet-
  measured rows on backward scroll (e.g. notes revealed by toggling
  WoT), so tall cards rendered far bigger than their recorded size.
  Removed — stable box heights make the flicker workaround obsolete.

Media boxes always render (stable height); the <img>/<video> inside
mounts only when the card is on screen, keeping scroll light.
This commit is contained in:
Jure
2026-05-18 20:12:28 +02:00
parent 9051c14205
commit 30f050119b
4 changed files with 77 additions and 80 deletions
+15 -11
View File
@@ -149,17 +149,21 @@ export function Feed() {
getScrollElement: () => scrollRef.current,
estimateSize: () => 140,
overscan: 6,
// Re-measuring rows during upward scroll shifts scrollTop a frame late →
// visible flicker. On backward scroll, reuse the cached measurement instead.
// (TanStack/virtual#659; falls back to a real measure if never cached.)
measureElement: (element, _entry, instance) => {
if (instance.scrollDirection === "forward" || instance.scrollDirection === null) {
return element.getBoundingClientRect().height;
}
const index = Number(element.getAttribute("data-index"));
const cached = instance.getVirtualItems().find((v) => v.index === index)?.size;
return cached ?? element.getBoundingClientRect().height;
},
// Key measurements by note id, not list index. The feed list mutates order
// constantly — new notes prepend (flushPendingNotes), the WoT filter removes
// mid-list items, tab switches swap the whole array. With the default
// index-based key, a cached row height would be reapplied to whatever note
// now sits at that index → cards overlap or leave gaps. Stable id keys pin
// each measurement to its own note.
getItemKey: (index) => filteredNotes[index]?.id ?? index,
// Default measureElement (real getBoundingClientRect + ResizeObserver).
// A previous custom measureElement reused cached sizes on backward scroll
// to dodge upward-scroll flicker (TanStack/virtual#659) — but for rows not
// yet measured (e.g. notes revealed by toggling the WoT filter) the
// "cache" was just the 140px estimate, so tall image notes rendered far
// bigger than their recorded size and overlapped their neighbours. Media
// now sits in fixed-aspect boxes, so row heights are stable and the
// flicker workaround is no longer needed.
});
// Keyboard nav (j/k) moves focusedNoteIndex; virtualization unmounts off-screen
+20 -9
View File
@@ -2,20 +2,31 @@ import { ContentSegment } from "../../lib/parsing";
import { usePodcastStore } from "../../stores/podcast";
import { safeHttpUrl } from "../../lib/utils";
export function VideoBlock({ sources }: { sources: string[] }) {
export function VideoBlock({ sources, inView }: { sources: string[]; inView: boolean }) {
if (sources.length === 0) return null;
return (
<div className="mt-2 flex flex-col gap-2">
{sources.map((src, i) => (
<video
// Fixed-aspect wrapper: the <video> resizes to the clip's intrinsic
// dimensions once `preload="metadata"` resolves. Inside a virtualized
// feed that late resize desyncs row measurements and overlaps cards,
// so the box height is deterministic. The <video> only mounts when the
// card is on screen (inView) — off-screen rows keep the empty box.
<div
key={i}
src={src}
controls
playsInline
preload="metadata"
className="max-w-full max-h-80 rounded-sm bg-bg-raised border border-border"
onError={(e) => { (e.target as HTMLVideoElement).style.display = "none"; }}
/>
className="w-full aspect-video rounded-sm bg-bg-raised border border-border overflow-hidden"
>
{inView && (
<video
src={src}
controls
playsInline
preload="metadata"
className="w-full h-full object-contain"
onError={(e) => { (e.target as HTMLVideoElement).style.display = "none"; }}
/>
)}
</div>
))}
</div>
);
+5 -1
View File
@@ -183,7 +183,11 @@ export const NoteCard = memo(function NoteCard({ event, focused, onReplyInThread
<div>
<NoteContent content={event.content} inline />
</div>
{inView && <NoteContent content={event.content} mediaOnly />}
{/* Media boxes always render (fixed aspect ratio → stable row
height for the virtualizer), but the heavy <img>/<video> inside
them only mounts when the card is on screen (mediaInView), so
off-screen overscan rows stay light and scrolling stays smooth. */}
<NoteContent content={event.content} mediaOnly mediaInView={inView} />
{/* Poll options — kind 1068 */}
{event.kind === 1068 && <PollWidget event={event} />}
+37 -59
View File
@@ -10,7 +10,7 @@ import { renderTextSegments } from "./TextSegments";
import { VideoBlock, AudioBlock, YouTubeCard, VimeoCard, SpotifyCard, TidalCard } from "./MediaCards";
import { FountainCard } from "./FountainCard";
function ImageGrid({ images, onImageClick }: { images: string[]; onImageClick: (index: number) => void }) {
function ImageGrid({ images, onImageClick, inView }: { images: string[]; onImageClick: (index: number) => void; inView: boolean }) {
const count = images.length;
if (count === 0) return null;
@@ -18,17 +18,27 @@ function ImageGrid({ images, onImageClick }: { images: string[]; onImageClick: (
const extraCount = count - 4;
const visible = images.slice(0, maxVisible);
// Each image sits in a fixed-aspect box, so the card height is deterministic
// before anything loads — the virtualizer can't mis-measure it. The <img>
// itself only mounts when the card is on screen (inView); off-screen rows
// keep just the empty box, so scrolling stays light.
const boxCls = "rounded-sm bg-bg-raised border border-border overflow-hidden cursor-zoom-in";
const cellImg = (src: string, idx: number) =>
inView ? (
<img
src={src}
alt="Posted image"
loading="lazy"
className="w-full h-full object-cover"
onClick={(e) => { e.stopPropagation(); onImageClick(idx); }}
onError={(e) => { (e.target as HTMLImageElement).style.display = "none"; }}
/>
) : null;
if (count === 1) {
return (
<div className="mt-2">
<img
src={images[0]}
alt="Posted image"
loading="lazy"
className="max-w-full max-h-80 rounded-sm object-cover bg-bg-raised border border-border cursor-zoom-in"
onClick={(e) => { e.stopPropagation(); onImageClick(0); }}
onError={(e) => { (e.target as HTMLImageElement).style.display = "none"; }}
/>
<div className={`w-full aspect-[4/3] ${boxCls}`}>{cellImg(images[0], 0)}</div>
</div>
);
}
@@ -37,15 +47,7 @@ function ImageGrid({ images, onImageClick }: { images: string[]; onImageClick: (
return (
<div className="mt-2 grid grid-cols-2 gap-1">
{visible.map((src, idx) => (
<img
key={idx}
src={src}
alt="Posted image"
loading="lazy"
className="w-full aspect-[4/3] rounded-sm object-cover bg-bg-raised border border-border cursor-zoom-in"
onClick={(e) => { e.stopPropagation(); onImageClick(idx); }}
onError={(e) => { (e.target as HTMLImageElement).style.display = "none"; }}
/>
<div key={idx} className={`aspect-[4/3] ${boxCls}`}>{cellImg(src, idx)}</div>
))}
</div>
);
@@ -54,30 +56,9 @@ function ImageGrid({ images, onImageClick }: { images: string[]; onImageClick: (
if (count === 3) {
return (
<div className="mt-2 grid grid-cols-2 grid-rows-2 gap-1">
<img
src={visible[0]}
alt="Posted image"
loading="lazy"
className="w-full h-full rounded-sm object-cover bg-bg-raised border border-border cursor-zoom-in row-span-2 aspect-[3/4]"
onClick={(e) => { e.stopPropagation(); onImageClick(0); }}
onError={(e) => { (e.target as HTMLImageElement).style.display = "none"; }}
/>
<img
src={visible[1]}
alt="Posted image"
loading="lazy"
className="w-full aspect-[4/3] rounded-sm object-cover bg-bg-raised border border-border cursor-zoom-in"
onClick={(e) => { e.stopPropagation(); onImageClick(1); }}
onError={(e) => { (e.target as HTMLImageElement).style.display = "none"; }}
/>
<img
src={visible[2]}
alt="Posted image"
loading="lazy"
className="w-full aspect-[4/3] rounded-sm object-cover bg-bg-raised border border-border cursor-zoom-in"
onClick={(e) => { e.stopPropagation(); onImageClick(2); }}
onError={(e) => { (e.target as HTMLImageElement).style.display = "none"; }}
/>
<div className={`row-span-2 ${boxCls}`}>{cellImg(visible[0], 0)}</div>
<div className={`aspect-[4/3] ${boxCls}`}>{cellImg(visible[1], 1)}</div>
<div className={`aspect-[4/3] ${boxCls}`}>{cellImg(visible[2], 2)}</div>
</div>
);
}
@@ -86,18 +67,11 @@ function ImageGrid({ images, onImageClick }: { images: string[]; onImageClick: (
return (
<div className="mt-2 grid grid-cols-2 gap-1">
{visible.map((src, idx) => (
<div key={idx} className="relative">
<img
src={src}
alt="Posted image"
loading="lazy"
className="w-full aspect-[4/3] rounded-sm object-cover bg-bg-raised border border-border cursor-zoom-in"
onClick={(e) => { e.stopPropagation(); onImageClick(idx); }}
onError={(e) => { (e.target as HTMLImageElement).style.display = "none"; }}
/>
<div key={idx} className={`relative aspect-[4/3] ${boxCls}`}>
{cellImg(src, idx)}
{idx === 3 && extraCount > 0 && (
<div
className="absolute inset-0 bg-bg/60 flex items-center justify-center rounded-sm cursor-zoom-in"
className="absolute inset-0 bg-bg/60 flex items-center justify-center cursor-zoom-in"
onClick={(e) => { e.stopPropagation(); onImageClick(idx); }}
>
<span className="text-text text-[14px] font-semibold">+{extraCount}</span>
@@ -148,9 +122,11 @@ interface NoteContentProps {
inline?: boolean;
/** Render only media blocks (videos, embeds, quotes). Used outside the clickable area. */
mediaOnly?: boolean;
/** When false, image/video boxes render empty — keeps off-screen rows light. */
mediaInView?: boolean;
}
export function NoteContent({ content, inline, mediaOnly }: NoteContentProps) {
export function NoteContent({ content, inline, mediaOnly, mediaInView = true }: NoteContentProps) {
const { openHashtag } = useUIStore();
const segments = parseContent(content);
const images: string[] = segments.filter((s) => s.type === "image").map((s) => s.value);
@@ -173,8 +149,10 @@ export function NoteContent({ content, inline, mediaOnly }: NoteContentProps) {
);
}
// --- 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.
// --- Media blocks only (rendered OUTSIDE the clickable wrapper) ---
// Split out so a click on media doesn't navigate to the thread. Images keep
// loading="lazy" so off-screen media isn't fetched; the boxes have a fixed
// aspect ratio so the card height is stable for the virtualizer.
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;
@@ -182,7 +160,7 @@ export function NoteContent({ content, inline, mediaOnly }: NoteContentProps) {
return (
<div onClick={(e) => e.stopPropagation()}>
<ImageGrid images={images} onImageClick={setLightboxIndex} />
<ImageGrid images={images} onImageClick={setLightboxIndex} inView={mediaInView} />
{lightboxIndex !== null && (
<ImageLightbox
images={images}
@@ -191,7 +169,7 @@ export function NoteContent({ content, inline, mediaOnly }: NoteContentProps) {
onNavigate={setLightboxIndex}
/>
)}
<VideoBlock sources={videos} />
<VideoBlock sources={videos} inView={mediaInView} />
<AudioBlock sources={audios} />
{youtubes.map((seg, i) => <YouTubeCard key={`yt-${i}`} seg={seg} />)}
{vimeos.map((seg, i) => <VimeoCard key={`vim-${i}`} seg={seg} />)}
@@ -210,7 +188,7 @@ export function NoteContent({ content, inline, mediaOnly }: NoteContentProps) {
{renderTextSegments(segments, openHashtag)}
</div>
<ImageGrid images={images} onImageClick={setLightboxIndex} />
<ImageGrid images={images} onImageClick={setLightboxIndex} inView={mediaInView} />
{lightboxIndex !== null && (
<ImageLightbox
@@ -221,7 +199,7 @@ export function NoteContent({ content, inline, mediaOnly }: NoteContentProps) {
/>
)}
<VideoBlock sources={videos} />
<VideoBlock sources={videos} inView={mediaInView} />
<AudioBlock sources={audios} />
{youtubes.map((seg, i) => <YouTubeCard key={`yt-${i}`} seg={seg} />)}
{vimeos.map((seg, i) => <VimeoCard key={`vim-${i}`} seg={seg} />)}