Skip to content

Commit 846f9d2

Browse files
luongndclaude
andcommitted
fix(v1.2.14): load-more via IntersectionObserver (Facebook-feed pattern)
User feedback: load-more chạy liên tục như spam. Root cause: scrollTop ≤ 120px heuristic + scroll restoration giữ user gần biên → mỗi event scroll re-trigger ngay sau khi load xong, vòng lặp vô tận cho đến hết transcript. Fix: thay scrollTop heuristic bằng IntersectionObserver trên top + bottom sentinels. Pattern giống Facebook news feed — observer fire 1 lần khi sentinel ENTER viewport (transition only, threshold 10% visibility), sau đó user phải scroll AWAY rồi scroll lại mới fire tiếp. Thêm 600ms cooldown an toàn nếu sentinel vẫn partially visible sau slide window. Removed handleTranscriptScroll + onScroll prop trên container. Label sentinel update: "cuộn đến đây để tải thêm" (rõ hơn "cuộn lên"). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent d1bae3f commit 846f9d2

1 file changed

Lines changed: 107 additions & 60 deletions

File tree

src/components/MeetingDetail.tsx

Lines changed: 107 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -486,7 +486,6 @@ export function MeetingDetail() {
486486
// reset to null so subsequent live-appended parts stay visible.
487487
const TRANSCRIPT_PAGE_SIZE = 200;
488488
const MAX_WINDOW_SIZE = 400; // hard DOM cap — slides instead of growing
489-
const SCROLL_LOAD_THRESHOLD = 120; // px from top OR bottom
490489
const [topAnchorChunkId, setTopAnchorChunkId] = useState<string | null>(null);
491490
const [bottomAnchorChunkId, setBottomAnchorChunkId] = useState<string | null>(null);
492491

@@ -579,64 +578,105 @@ export function MeetingDetail() {
579578
el.scrollTop += newTop - anchor.viewportTop;
580579
};
581580

582-
// Single scroll handler — fires up-load OR down-load depending on which
583-
// edge the user is near. Throttled via inflight ref so a momentum bounce
584-
// doesn't trigger 2 loads in a row.
581+
// v1.2.14: load-more via IntersectionObserver (Facebook-feed pattern).
582+
// Replaces the scrollTop-threshold heuristic which kept firing as the user
583+
// sat near the edge — observer fires ONLY on visibility transitions, plus
584+
// a 600ms cooldown safety net so a window re-render that keeps the
585+
// sentinel partially visible can't burst into a load loop.
585586
const loadingMoreRef = useRef(false);
586-
const handleTranscriptScroll = useCallback(() => {
587+
const topSentinelRef = useRef<HTMLDivElement>(null);
588+
const bottomSentinelRef = useRef<HTMLDivElement>(null);
589+
590+
const loadOlder = useCallback(() => {
587591
if (loadingMoreRef.current) return;
588592
const el = transcriptRef.current;
589-
if (!el) return;
593+
if (!el || !hasOlderParts) return;
594+
loadingMoreRef.current = true;
590595

591-
const nearTop = el.scrollTop <= SCROLL_LOAD_THRESHOLD;
592-
const distanceFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight;
593-
const nearBottom = distanceFromBottom <= SCROLL_LOAD_THRESHOLD;
596+
const anchor = captureScrollAnchor(el);
597+
const total = transcriptParts.length;
598+
// Slide window backward by one page; cap size at MAX_WINDOW_SIZE.
599+
// Keep tail open when newEnd === total so live appends still flow.
600+
const newStart = Math.max(0, visibleStartIdx - TRANSCRIPT_PAGE_SIZE);
601+
const newEnd = Math.min(total, newStart + MAX_WINDOW_SIZE);
602+
setTopAnchorChunkId(newStart > 0 ? (transcriptParts[newStart]?.chunkId || null) : null);
603+
setBottomAnchorChunkId(
604+
newEnd < total ? (transcriptParts[newEnd - 1]?.chunkId || null) : null,
605+
);
594606

595-
const wantsUpLoad = nearTop && hasOlderParts;
596-
const wantsDownLoad = nearBottom && hasNewerParts;
597-
if (!wantsUpLoad && !wantsDownLoad) return;
607+
requestAnimationFrame(() => {
608+
const elNow = transcriptRef.current;
609+
if (elNow) restoreScrollAnchor(elNow, anchor);
610+
// Cooldown — prevents instant re-trigger if the sentinel happens
611+
// to remain partially visible after the slide. User must scroll
612+
// away + back to load the next page.
613+
setTimeout(() => { loadingMoreRef.current = false; }, 600);
614+
});
615+
}, [hasOlderParts, visibleStartIdx, transcriptParts]);
598616

617+
const loadNewer = useCallback(() => {
618+
if (loadingMoreRef.current) return;
619+
const el = transcriptRef.current;
620+
if (!el || !hasNewerParts) return;
599621
loadingMoreRef.current = true;
622+
600623
const anchor = captureScrollAnchor(el);
601624
const total = transcriptParts.length;
602-
603-
if (wantsUpLoad) {
604-
// Slide window backward by one page; cap size at MAX_WINDOW_SIZE
605-
// by trimming the same number of parts off the bottom. Keep
606-
// tail open when newEnd === total so live appends still flow
607-
// naturally — visibleStartIdx's hard cap (start ≥ end - MAX)
608-
// prevents unbounded growth even in tail-open mode.
609-
const newStart = Math.max(0, visibleStartIdx - TRANSCRIPT_PAGE_SIZE);
610-
const newEnd = Math.min(total, newStart + MAX_WINDOW_SIZE);
611-
setTopAnchorChunkId(newStart > 0 ? (transcriptParts[newStart]?.chunkId || null) : null);
612-
setBottomAnchorChunkId(
613-
newEnd < total ? (transcriptParts[newEnd - 1]?.chunkId || null) : null,
614-
);
615-
} else if (wantsDownLoad) {
616-
// Slide window forward by one page; cap size at MAX_WINDOW_SIZE
617-
// by trimming the same number of parts off the top. When the new
618-
// window reaches the array tail, UNSET BOTH anchors to drop back
619-
// into default tail-follow mode (window collapses to PAGE_SIZE,
620-
// live appends auto-flow into view).
621-
const newEnd = Math.min(total, visibleEndIdx + TRANSCRIPT_PAGE_SIZE);
622-
const newStart = Math.max(0, newEnd - MAX_WINDOW_SIZE);
623-
const reachedTail = newEnd === total;
624-
setTopAnchorChunkId(
625-
reachedTail || newStart === 0
626-
? null
627-
: (transcriptParts[newStart]?.chunkId || null),
628-
);
629-
setBottomAnchorChunkId(
630-
reachedTail ? null : (transcriptParts[newEnd - 1]?.chunkId || null),
631-
);
632-
}
625+
// Slide window forward by one page. When new window reaches the array
626+
// tail, UNSET BOTH anchors to drop back into default tail-follow mode.
627+
const newEnd = Math.min(total, visibleEndIdx + TRANSCRIPT_PAGE_SIZE);
628+
const newStart = Math.max(0, newEnd - MAX_WINDOW_SIZE);
629+
const reachedTail = newEnd === total;
630+
setTopAnchorChunkId(
631+
reachedTail || newStart === 0
632+
? null
633+
: (transcriptParts[newStart]?.chunkId || null),
634+
);
635+
setBottomAnchorChunkId(
636+
reachedTail ? null : (transcriptParts[newEnd - 1]?.chunkId || null),
637+
);
633638

634639
requestAnimationFrame(() => {
635640
const elNow = transcriptRef.current;
636641
if (elNow) restoreScrollAnchor(elNow, anchor);
637-
loadingMoreRef.current = false;
642+
setTimeout(() => { loadingMoreRef.current = false; }, 600);
638643
});
639-
}, [hasOlderParts, hasNewerParts, visibleStartIdx, visibleEndIdx, transcriptParts]);
644+
}, [hasNewerParts, visibleEndIdx, transcriptParts]);
645+
646+
// Attach IntersectionObserver to whichever sentinels are mounted. The
647+
// observer fires only on viewport-entry transitions (isIntersecting
648+
// true → only when the user scrolls so the sentinel comes INTO view from
649+
// OFF-screen). After load, scroll restoration keeps user at the same
650+
// chunk → sentinel slides OUT of viewport → observer state resets, ready
651+
// for the next user-initiated scroll to top/bottom.
652+
useEffect(() => {
653+
const container = transcriptRef.current;
654+
if (!container) return;
655+
656+
const observer = new IntersectionObserver(
657+
(entries) => {
658+
for (const entry of entries) {
659+
if (!entry.isIntersecting) continue;
660+
if (entry.target === topSentinelRef.current) {
661+
loadOlder();
662+
} else if (entry.target === bottomSentinelRef.current) {
663+
loadNewer();
664+
}
665+
}
666+
},
667+
{
668+
root: container,
669+
// 10% visibility = clear "user actually scrolled to see it".
670+
// Lower would fire on momentum bounces; higher would require
671+
// dragging fully on-screen which feels sluggish.
672+
threshold: 0.1,
673+
},
674+
);
675+
676+
if (topSentinelRef.current) observer.observe(topSentinelRef.current);
677+
if (bottomSentinelRef.current) observer.observe(bottomSentinelRef.current);
678+
return () => observer.disconnect();
679+
}, [loadOlder, loadNewer, hasOlderParts, hasNewerParts]);
640680

641681
const persistTranscriptParts = async (parts: TranscriptPart[]) => {
642682
const meetingId = currentMeetingId || draftId;
@@ -1156,7 +1196,6 @@ export function MeetingDetail() {
11561196
<div
11571197
className={`transcript-list ${translationEnabled ? 'with-translation' : ''}`}
11581198
ref={transcriptRef}
1159-
onScroll={handleTranscriptScroll}
11601199
>
11611200
{/* v1.2.14: Internet-offline banner. Self-renders only
11621201
when the backend's TCP probe (1.1.1.1:443) flips to
@@ -1220,17 +1259,22 @@ export function MeetingDetail() {
12201259
</div>
12211260
);
12221261
})()}
1223-
{/* Sentinel shown at top of list when older parts
1224-
are hidden. Pure status indicator — no click
1225-
handler. Scrolling within SCROLL_LOAD_THRESHOLD
1226-
px of this sentinel auto-loads the next page. */}
1262+
{/* Top sentinel — fires IntersectionObserver when it
1263+
scrolls into view (10% visible), triggering loadOlder.
1264+
Only one load per user-initiated scroll into the
1265+
sentinel — observer fires on transition only, plus
1266+
600ms cooldown safety net. */}
12271267
{hasOlderParts && (
1228-
<div className="transcript-load-sentinel" aria-live="polite">
1268+
<div
1269+
ref={topSentinelRef}
1270+
className="transcript-load-sentinel"
1271+
aria-live="polite"
1272+
>
12291273
<span className="transcript-load-sentinel-spinner" aria-hidden />
12301274
<span>
12311275
{lang === 'vi'
1232-
? `Đang ẩn ${visibleStartIdx} đoạn cũ hơn — cuộn lên để tải thêm`
1233-
: `${visibleStartIdx} older part(s) hidden — scroll up to load more`}
1276+
? `Đang ẩn ${visibleStartIdx} đoạn cũ hơn — cuộn đến đây để tải thêm`
1277+
: `${visibleStartIdx} older part(s) hidden — scroll here to load more`}
12341278
</span>
12351279
</div>
12361280
)}
@@ -1317,17 +1361,20 @@ export function MeetingDetail() {
13171361
</div>
13181362
);
13191363
})}
1320-
{/* Bottom sentinel — surfaces when the sliding window
1321-
has trimmed newer parts off the bottom (user scrolled
1322-
up past MAX_WINDOW_SIZE then back). Cuộn xuống tự
1323-
động slide window forward. */}
1364+
{/* Bottom sentinel — IntersectionObserver triggers
1365+
loadNewer when it reaches viewport. Same one-shot
1366+
transition + 600ms cooldown semantics as top. */}
13241367
{hasNewerParts && (
1325-
<div className="transcript-load-sentinel" aria-live="polite">
1368+
<div
1369+
ref={bottomSentinelRef}
1370+
className="transcript-load-sentinel"
1371+
aria-live="polite"
1372+
>
13261373
<span className="transcript-load-sentinel-spinner" aria-hidden />
13271374
<span>
13281375
{lang === 'vi'
1329-
? `Đang ẩn ${transcriptParts.length - visibleEndIdx} đoạn mới hơn — cuộn xuống để tải`
1330-
: `${transcriptParts.length - visibleEndIdx} newer part(s) hidden — scroll down to load`}
1376+
? `Đang ẩn ${transcriptParts.length - visibleEndIdx} đoạn mới hơn — cuộn đến đây để tải`
1377+
: `${transcriptParts.length - visibleEndIdx} newer part(s) hidden — scroll here to load`}
13311378
</span>
13321379
</div>
13331380
)}

0 commit comments

Comments
 (0)